feat: init commit

This commit is contained in:
vilm3r
2022-06-22 19:10:05 -05:00
parent e612c6a693
commit c1b41b5ebd
41 changed files with 17652 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
type ButtonProps = {
onClick: () => void
children: React.ReactNode
disabled?: boolean
className?: string
}
export const Button = ({ children, disabled, onClick, className }: ButtonProps) => {
return (
<div className={className}>
<button
className="bg-custom-black rounded-md px-6 py-3 w-full h-full shadow"
onClick={onClick}
disabled={disabled}
>
{children}
</button>
</div>
)
}

View File

@@ -0,0 +1,7 @@
interface CardProps {
children: React.ReactNode
}
export const Card = ({ children }: CardProps) => {
return <div className="bg-custom-green-light rounded-xl shadow-md">{children}</div>
}

View File

@@ -0,0 +1,31 @@
import React from "react"
import { DiGithubBadge } from "react-icons/di"
import { MdSettings } from "react-icons/md"
import Link from "next/link"
export function Header() {
return (
<header className="bg-custom-green-light mb-5 mx-auto border-0 rounded-xl p-4 shadow-md">
<div className="container flex justify-between items-center">
<a href="/" className="py-1.5 mr-4 text-lg cursor-pointer">
<h1 className="text-xl">Sendstr</h1>
</a>
<div className="flex items-center space-x-3">
{/* <Link
href="/faq"
className="text-lg cursor-pointer"
>FAQ
</Link> */}
<a href="https://github.com/vilm3r/sendstr-web">
<DiGithubBadge className="inline text-3xl" title="Github" />
</a>
<Link href="/settings">
<div className="cursor-pointer">
<MdSettings className="inline text-2xl" title="Settings" />
</div>
</Link>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,26 @@
import { forwardRef, LegacyRef } from "react"
type IconButtonProps = {
onClick: () => void
children: React.ReactNode
className?: string
}
export const IconButton = forwardRef(
(
{ children, onClick, className }: IconButtonProps,
ref: LegacyRef<HTMLButtonElement> | undefined,
) => {
return (
<div className={className}>
<button
ref={ref}
className="bg-custom-black w-full h-full rounded-lg flex justify-center items-center"
onClick={onClick}
>
{children}
</button>
</div>
)
},
)

View File

@@ -0,0 +1,27 @@
import { ChangeEvent, forwardRef, LegacyRef } from "react"
export interface InputProps {
onChange?: (e: ChangeEvent<HTMLInputElement> | undefined) => void
value?: string
placeholder?: string
className?: string
}
export const Input = forwardRef(
(
{ onChange, value, placeholder, className }: InputProps,
ref: LegacyRef<HTMLInputElement> | undefined,
) => {
return (
<div className={className}>
<input
className="bg-custom-green-dark border-2 border-custom-black rounded w-full p-3"
ref={ref}
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</div>
)
},
)

View File

@@ -0,0 +1,33 @@
import { BrowserQRCodeReader, IScannerControls } from "@zxing/browser"
import { useEffect, useRef } from "react"
export const QrReader = ({ onResult }: { onResult: (result: string) => void }) => {
const controlsRef = useRef<IScannerControls | null>(null)
const constraints = {
video: {
facingMode: {
ideal: "environment",
},
},
}
useEffect(() => {
const reader = new BrowserQRCodeReader(undefined, {
delayBetweenScanAttempts: 300,
})
reader
.decodeFromConstraints(constraints, "video", (result, error, controls) => {
controlsRef.current = controls
if (result) {
controls.stop()
onResult(result.getText())
}
})
.catch(console.warn)
return () => controlsRef?.current?.stop()
}, [])
return <video id="video"></video>
}

View File

@@ -0,0 +1,24 @@
type ToggleProps = {
checked: boolean
onChange: () => void
}
export const Toggle = ({ checked, onChange }: ToggleProps) => {
return (
<label>
<input
type="checkbox"
className="absolute overflow-hidden whitespace-nowrap h-[1px] w-[1px]"
checked={checked}
onChange={onChange}
/>
<span className="bg-white border-2 border-custom-black rounded-3xl flex h-8 mr-[10px] relative w-16 cursor-pointer">
<span
className={`flex absolute left-[2px] bottom-[2px] justify-center h-6 w-6 rounded-full items-center transition ${
checked ? "bg-custom-black" : "translate-x-8 bg-custom-black/50"
}`}
></span>
</span>
</label>
)
}

42
src/lib/localStorage.ts Normal file
View File

@@ -0,0 +1,42 @@
export type SettingsRelay = {
url: string
enabled: boolean
}
export const getRelays = (): SettingsRelay[] =>
JSON.parse(
window.localStorage.getItem("relays") ||
JSON.stringify([
{
url: "wss://relay.sendstr.com",
enabled: true,
},
]),
) as SettingsRelay[]
export const setRelays = (relays: SettingsRelay[]) =>
window.localStorage.setItem("relays", JSON.stringify(relays))
export const addRelay = (relay: SettingsRelay) =>
window.localStorage.setItem("relays", JSON.stringify([...getRelays(), relay]))
export const removeRelay = (relay: string) =>
window.localStorage.setItem("relays", JSON.stringify(getRelays().filter((x) => x.url !== relay)))
export const toggleRelay = (relay: string) =>
window.localStorage.setItem(
"relays",
JSON.stringify(
getRelays().reduce((acc, x) => {
if (x.url === relay)
return [
...acc,
{
url: x.url,
enabled: !x.enabled,
},
]
return [...acc, x]
}, [] as SettingsRelay[]),
),
)

10
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,10 @@
export const debounce = (callback: (...args: any[]) => void, wait: number) => {
let timeoutId: NodeJS.Timeout
return (...args: any) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
callback(...args)
}, wait)
}
}

70
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,70 @@
import "../styles/global.css"
import { AppProps } from "next/app"
import { useEffect, useState } from "react"
import { getRelays } from "../lib/localStorage"
import { NostrEventType, NostrType } from "../types"
export default function App({ Component, pageProps }: AppProps) {
const [events, setEvents] = useState<{ [k: string]: NostrEventType }>({})
const [nostr, setNostr] = useState<NostrType>({
priv: "",
pub: "",
pool: null,
sub: null,
})
const updateEvents = async (pub: string, priv: string, event: NostrEventType) => {
const { decrypt } = await import("nostr-tools/nip04")
try {
const p = event.tags.find(([tag]) => tag === "p") || ["p", ""]
const pubkey = event.pubkey === pub ? p[1] : event.pubkey
const message = decrypt(priv, pubkey, event.content)
setEvents({
...events,
...{
[event.id]: {
...event,
message,
},
},
})
} catch (e) {
console.warn(e)
}
}
useEffect(() => {
let sub: {
unsub: () => void
}
void (async () => {
const { generatePrivateKey, relayPool, getPublicKey } = await import("nostr-tools")
const priv = generatePrivateKey()
const pub = getPublicKey(priv)
const pool = relayPool()
pool.setPrivateKey(priv)
const relays = getRelays()
relays.forEach(
(relay) => relay.enabled && pool.addRelay(relay.url, { read: true, write: true }),
)
sub = pool.sub({
cb: (event: NostrEventType) => updateEvents(pub, priv, event),
filter: [{ "#p": [pub] }],
})
setNostr({ priv, pub, pool, sub })
})()
return () => {
sub.unsub()
}
}, [])
const getLatestEvent = (events: Record<string, NostrEventType>) =>
Object.entries(events).reduce((acc, x) => {
if (acc === null) return x[1]
if (new Date(acc.created_at) < new Date(x[1].created_at)) return x[1]
return acc
}, null as NostrEventType | null)
return <Component {...{ ...pageProps, nostr, event: getLatestEvent(events) }} />
}

3
src/pages/faq/index.tsx Normal file
View File

@@ -0,0 +1,3 @@
export default function FAQ() {
return <></>
}

82
src/pages/index.tsx Normal file
View File

@@ -0,0 +1,82 @@
import React, { useState } from "react"
import Head from "next/head"
import { Header } from "../components/header"
import { Button } from "../components/button"
import { Card } from "../components/card"
import { SendView } from "../views/send"
import { ReceiveView } from "../views/receive"
import { NostrEventType, NostrType } from "../types"
type HomeProps = {
nostr: NostrType
event: NostrEventType
}
export default function Home({ nostr, event }: HomeProps) {
const [clientType, setClientType] = useState("")
const LandingView = () => (
<div className="max-w-[64rem] m-auto">
<Card>
<div className="p-10">
<h1 className="text-2xl text-bold pb-5">Open source e2e encrypted shared clipboard</h1>
<p className="pb-10">
Sendstr is an open source end-to-end encrypted shared clipboard app built on top of{" "}
<a className="underline" href="https://github.com/nostr-protocol/nostr" target="_blank">
Nostr
</a>
. No login needed, new throwaway encryption keys are generated on page load, and the
default relay deletes messages after 1 hour. To get started open this page on another
device and choose one of the options below.
</p>
<div className="flex w-full">
<Button className="w-1/2 px-4" onClick={() => setClientType("send")}>
Send
</Button>
<Button className="w-1/2 px-4" onClick={() => setClientType("receive")}>
Receive
</Button>
</div>
</div>
</Card>
</div>
)
const Page = () => {
switch (true) {
case clientType === "send":
return <SendView nostr={nostr} />
case clientType === "receive":
return <ReceiveView nostr={nostr} event={event} />
default:
return <LandingView />
}
}
return (
<>
<Head>
<title>Sendstr</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta
name="description"
content="Sendstr is an open source end-to-end encrypted shared clipboard app
built on top of Nostr. No login needed, new throwaway encryption keys are generated on
page load, and the default relay deletes messages after 1 hour."
/>
<meta name="mobile-web-app-capable" content="yes" />
</Head>
<div className="bg-custom-green-dark min-h-screen">
<div className="p-5">
<div className="max-w-[80rem] mx-auto">
<Header />
</div>
<main>
<Page />
</main>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,101 @@
import { createRef, useState } from "react"
import { Button } from "../../components/button"
import { Card } from "../../components/card"
import { Header } from "../../components/header"
import {
SettingsRelay,
getRelays,
addRelay,
toggleRelay,
removeRelay,
} from "../../lib/localStorage"
import { Input } from "../../components/input"
import { MdDelete } from "react-icons/md"
import { Toggle } from "../../components/toggle"
import Head from "next/head"
type SettingsState = {
relays: SettingsRelay[]
}
export default function Settings() {
const [settings, setSettings] = useState<SettingsState>({
relays: typeof window !== "undefined" ? getRelays() : [],
})
const newPool = createRef<HTMLInputElement>()
return (
<>
<Head>
<title>Sendstr - Settings</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta
name="description"
content="Sendstr is an open source end-to-end encrypted shared clipboard app
built on top of Nostr. No login needed, new throwaway encryption keys are generated on
page load, and the default relay deletes messages after 1 hour."
/>
</Head>
<div className="bg-custom-green-dark min-h-screen">
<div className="p-5 max-w-[80rem] m-auto">
<Header />
<main className="max-w-[64rem] m-auto">
<Card>
<div className="max-w-[30rem] m-auto p-10">
<h2 className="text-2xl pb-5">Relays</h2>
<ul>
{settings.relays.map((relay) => (
<li key={relay.url}>
<div className="m-auto flex items-center pb-3">
<Toggle
checked={relay.enabled}
onChange={() => {
toggleRelay(relay.url)
setSettings({
...settings,
relays: getRelays(),
})
}}
/>
<label className="lg:text-lg flex-grow text-center p-2 truncate">
{relay.url}
</label>
<button
onClick={() => {
removeRelay(relay.url)
setSettings({
...settings,
relays: getRelays(),
})
}}
>
<MdDelete className="text-2xl" />
</button>
</div>
</li>
))}
</ul>
<Input className="pt-5" ref={newPool} placeholder="Relay url" />
<Button
className="pt-5"
onClick={() => {
addRelay({
url: newPool?.current?.value || "",
enabled: true,
})
setSettings({
...settings,
relays: getRelays(),
})
}}
>
Add Relay
</Button>
</div>
</Card>
</main>
</div>
</div>
</>
)
}

7
src/styles/global.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
color: #FFFFFF
}

59
src/types.ts Normal file
View File

@@ -0,0 +1,59 @@
export type NostrSub = ({
cb,
filter,
}: {
cb: (event: {
content: string
created_at: number
id: string
kind: number
pubkey: string
message: string
sig: string
tags: [[string, string]]
}) => Promise<void>
filter: Record<string, string[]>[]
}) => {
unsub: () => void
}
export type NostrPublish = ({
pubkey,
created_at,
kind,
tags,
content,
}: {
pubkey: string
created_at: number
kind: number
tags: [[string, string]]
content: string
}) => Promise<void>
export type NostrPool = {
setPrivateKey: (priv: string) => void
addRelay: (url: string, { read, write }: { read: boolean; write: boolean }) => void
sub: NostrSub
publish: NostrPublish
}
export type NostrType = {
priv: string
pub: string
pool: NostrPool | null
sub: {
unsub: () => void
} | null
}
export type NostrEventType = {
content: string
created_at: number
id: string
kind: number
pubkey: string
message: string
sig: string
tags: [[string, string]]
}

View File

@@ -0,0 +1,75 @@
import { QRCodeSVG } from "qrcode.react"
import Toastify from "toastify-js"
import { Button } from "../../components/button"
import { Card } from "../../components/card"
import { NostrEventType, NostrType } from "../../types"
const Message = ({ event }: { event: NostrEventType }) => {
return (
<p className="bg-custom-green-dark border-2 border-custom-black rounded w-full p-3 whitespace-pre-wrap">
{event.message}
</p>
)
}
type ReceiveViewProps = {
nostr: NostrType
event: NostrEventType
}
export const ReceiveView = ({ nostr, event }: ReceiveViewProps) => {
return (
<div className="max-w-[64rem] m-auto">
<Card>
<div className="p-10">
<div className="flex flex-col lg:flex-row">
{!event && (
<div className="overflow-visible py-5 max-w-[20rem] mx-auto lg:pr-5">
<QRCodeSVG
value={nostr.pub}
level="H"
bgColor="transparent"
fgColor="#3C3744"
includeMargin={false}
width="100%"
height="100%"
/>
</div>
)}
<div className="flex flex-col items-center justify-center w-full">
<div>
<label className="flex flex-grow text-left">My pubkey:</label>
<div
id="mypubkey"
className="border-2 border-custom-black rounded-md p-2 bg-custom-green-dark break-all"
>
{nostr.pub}
</div>
<div className="py-6 max-w-[20rem] m-auto">
<Button
onClick={() => {
navigator.clipboard.writeText(nostr.pub).catch(console.warn)
Toastify({
text: "Pubkey copied",
duration: 2000,
close: false,
gravity: "bottom",
position: "center",
stopOnFocus: false,
className:
"flex fixed bottom-0 bg-custom-black p-2 rounded left-[45%] z-50",
}).showToast()
}}
>
Copy Pubkey
</Button>
</div>
</div>
</div>
</div>
<div>{event && <Message event={event} />}</div>
</div>
</Card>
</div>
)
}

20
src/views/scan/index.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { MdClose } from "react-icons/md"
import { QrReader } from "../../components/qr-reader"
interface ScanViewProps {
close: () => void
onScan: (x: string) => void
}
export const ScanView = ({ close, onScan }: ScanViewProps) => {
return (
<div className="bg-black h-screen w-screen fixed top-0 left-0 z-50 flex justify-center">
<QrReader onResult={onScan} />
<div className="absolute text-black top-0 right-0">
<button className="bg-white rounded-3xl p-2 m-2" onClick={close}>
<MdClose size="1rem" />
</button>
</div>
</div>
)
}

118
src/views/send/index.tsx Normal file
View File

@@ -0,0 +1,118 @@
import dynamic from "next/dynamic"
import { useEffect, useRef, useState } from "react"
import { isMobile } from "react-device-detect"
import { Input } from "../../components/input"
import { MdQrCodeScanner } from "react-icons/md"
import { IconButton } from "../../components/icon-button"
import { Card } from "../../components/card"
import { debounce } from "../../lib/utils"
import { NostrType } from "../../types"
type MessageProps = {
message: string
onChange: (x: string) => void
}
const isValidPeerKey = (peerKey: string) => peerKey.length === 64
const Message = ({ message, onChange }: MessageProps) => {
return (
<section className="p-4">
<div className="border-0">
<textarea
className="bg-custom-green-dark border-2 border-custom-black rounded w-full min-h-[100px] max-h-[700px]"
value={message}
onChange={(e) => onChange(e.currentTarget.value || "")}
/>
</div>
</section>
)
}
type PeerInputProps = {
peerKey: string
onChange: (x: string) => void
setShowScan: (x: boolean) => void
}
const PeerInput = ({ peerKey, onChange, setShowScan }: PeerInputProps) => {
return (
<section className="mx-auto max-w-[40rem] p-4">
<label>Peer pubkey:</label>
<div className="relative">
<Input value={peerKey} onChange={(e) => onChange(e?.currentTarget?.value || "")} />
<div className="absolute right-0 top-0 h-full flex items-center">
<IconButton className="w-10 h-10 mr-2" onClick={() => setShowScan(true)}>
<div className="">
<MdQrCodeScanner width="100%" height="auto" />
</div>
</IconButton>
</div>
</div>
</section>
)
}
export type SendViewProps = {
nostr: NostrType
}
export const SendView = ({ nostr }: SendViewProps) => {
const [showScan, setShowScan] = useState(false)
const [peerKey, setPeerKey] = useState("")
const [message, setMessage] = useState("")
const ScanView = dynamic(async () => (await import("../scan")).ScanView)
useEffect(() => {
if (isMobile) {
setShowScan(true)
}
}, [])
const sendNostrMessage = useRef(
// eslint-disable-next-line @typescript-eslint/no-misused-promises
debounce(async (m: string, p: string, n: NostrType) => {
try {
const { encrypt } = await import("nostr-tools/nip04")
await n.pool?.publish({
pubkey: n.pub,
created_at: Math.round(Date.now() / 1000),
kind: 4,
tags: [["p", p]],
content: encrypt(n.priv, p, m),
})
} catch (e) {
console.warn(e)
}
}, 750),
).current
const sendMessage = (message: string) => {
setMessage(message)
sendNostrMessage(message, peerKey, nostr)
}
return (
<div>
{showScan && (
<ScanView
close={() => setShowScan(false)}
onScan={(x: string) => {
setShowScan(false)
setPeerKey(x)
}}
/>
)}
<div className="mx-auto max-w-[64rem] flex flex-col gap-5">
<Card>
<div className="p-4">
<PeerInput peerKey={peerKey} setShowScan={setShowScan} onChange={setPeerKey} />
{isValidPeerKey(peerKey) && <Message message={message} onChange={sendMessage} />}
</div>
</Card>
</div>
</div>
)
}