feat: two way collab

This commit is contained in:
vilm3r
2022-06-23 18:36:56 -05:00
parent f17c4b8a1d
commit 819d8210ea
9 changed files with 219 additions and 106 deletions

View File

@@ -17,7 +17,7 @@ Then copy the contents of `./out` to your favorite static content host.
## Future Features ## Future Features
- [ ] Two-way collaboration - [X] Two-way collaboration
- [ ] File sharing - [ ] File sharing
- [ ] FAQ Page - [ ] FAQ Page
- [ ] Themes and Light/Dark support - [ ] Themes and Light/Dark support

2
global.d.ts vendored
View File

@@ -29,7 +29,7 @@ declare module "nostr-tools" {
message: string message: string
sig: string sig: string
tags: [[string, string]] tags: [[string, string]]
}) => Promise<void> }) => void
filter: Record<string, string[]>[] filter: Record<string, string[]>[]
}) => { }) => {
unsub: () => void unsub: () => void

View File

@@ -1,6 +1,6 @@
{ {
"name": "sendstr-web", "name": "sendstr-web",
"version": "1.0.0", "version": "1.1.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

68
src/lib/nostr.ts Normal file
View File

@@ -0,0 +1,68 @@
import { getRelays } from "./localStorage"
import { NostrEventType, NostrKeysType, NostrPoolType } from "../types"
export 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)
export const getReceivePeerKey = (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)?.pubkey
export const subscribe = async (
keys: NostrKeysType,
peerKey: string,
cb: (event: NostrEventType) => void,
) => {
const { decrypt } = await import("nostr-tools/nip04")
const { relayPool } = await import("nostr-tools")
const pool = relayPool()
pool.setPrivateKey(keys.priv)
const relays = getRelays()
relays.forEach((relay) => relay.enabled && pool.addRelay(relay.url, { read: true, write: true }))
const sub = pool.sub({
cb: (event: NostrEventType) => {
try {
const p = event.tags.find(([tag]) => tag === "p") || ["p", ""]
const pubkey = event.pubkey === keys.pub ? p[1] : event.pubkey
const message = decrypt(keys.priv, pubkey, event.content)
cb({ ...event, message })
} catch (e) {
console.warn(e)
}
},
filter: [{ "#p": [keys.pub, peerKey] }, { authors: [keys.pub, peerKey] }],
})
return { sub, pool }
}
type SendEncryptedMessage = {
pool: NostrPoolType | null
priv: string
pub: string
peerKey: string
message: string
}
export const sendEncryptedMessage = async ({
pool,
priv,
pub,
peerKey,
message,
}: SendEncryptedMessage) => {
const { encrypt } = await import("nostr-tools/nip04")
return pool?.publish({
pubkey: pub,
created_at: Math.round(Date.now() / 1000),
kind: 4,
tags: [["p", peerKey]],
content: encrypt(priv, peerKey, message),
})
}

View File

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

View File

@@ -6,14 +6,13 @@ import { Button } from "../components/button"
import { Card } from "../components/card" import { Card } from "../components/card"
import { SendView } from "../views/send" import { SendView } from "../views/send"
import { ReceiveView } from "../views/receive" import { ReceiveView } from "../views/receive"
import { NostrEventType, NostrType } from "../types" import { NostrKeysType } from "../types"
type HomeProps = { type HomeProps = {
nostr: NostrType keys: NostrKeysType
event: NostrEventType
} }
export default function Home({ nostr, event }: HomeProps) { export default function Home({ keys }: HomeProps) {
const [clientType, setClientType] = useState("") const [clientType, setClientType] = useState("")
const LandingView = () => ( const LandingView = () => (
@@ -46,9 +45,9 @@ export default function Home({ nostr, event }: HomeProps) {
const Page = () => { const Page = () => {
switch (true) { switch (true) {
case clientType === "send": case clientType === "send":
return <SendView nostr={nostr} /> return <SendView keys={keys} />
case clientType === "receive": case clientType === "receive":
return <ReceiveView nostr={nostr} event={event} /> return <ReceiveView keys={keys} />
default: default:
return <LandingView /> return <LandingView />
} }

View File

@@ -1,4 +1,4 @@
export type NostrSub = ({ export type NostrSubType = ({
cb, cb,
filter, filter,
}: { }: {
@@ -11,13 +11,13 @@ export type NostrSub = ({
message: string message: string
sig: string sig: string
tags: [[string, string]] tags: [[string, string]]
}) => Promise<void> }) => void
filter: Record<string, string[]>[] filter: Record<string, string[]>[]
}) => { }) => {
unsub: () => void unsub: () => void
} }
export type NostrPublish = ({ export type NostrPublishType = ({
pubkey, pubkey,
created_at, created_at,
kind, kind,
@@ -31,17 +31,17 @@ export type NostrPublish = ({
content: string content: string
}) => Promise<void> }) => Promise<void>
export type NostrPool = { export type NostrPoolType = {
setPrivateKey: (priv: string) => void setPrivateKey: (priv: string) => void
addRelay: (url: string, { read, write }: { read: boolean; write: boolean }) => void addRelay: (url: string, { read, write }: { read: boolean; write: boolean }) => void
sub: NostrSub sub: NostrSubType
publish: NostrPublish publish: NostrPublishType
} }
export type NostrType = { export type NostrType = {
priv: string priv: string
pub: string pub: string
pool: NostrPool | null pool: NostrPoolType | null
sub: { sub: {
unsub: () => void unsub: () => void
} | null } | null
@@ -57,3 +57,8 @@ export type NostrEventType = {
sig: string sig: string
tags: [[string, string]] tags: [[string, string]]
} }
export type NostrKeysType = {
pub: string
priv: string
}

View File

@@ -1,32 +1,80 @@
import { QRCodeSVG } from "qrcode.react" import { QRCodeSVG } from "qrcode.react"
import { useEffect, useRef, useState } from "react"
import Toastify from "toastify-js" import Toastify from "toastify-js"
import { Button } from "../../components/button" import { Button } from "../../components/button"
import { Card } from "../../components/card" import { Card } from "../../components/card"
import { NostrEventType, NostrType } from "../../types" import { getLatestEvent, getReceivePeerKey, sendEncryptedMessage, subscribe } from "../../lib/nostr"
import { debounce } from "../../lib/utils"
import { NostrEventType, NostrKeysType, NostrType } from "../../types"
const Message = ({ event }: { event: NostrEventType }) => { type MessageProps = {
message: string
onChange: (x: string) => void
}
const Message = ({ message, onChange }: MessageProps) => {
return ( return (
<p className="bg-custom-green-dark border-2 border-custom-black rounded w-full p-3 whitespace-pre-wrap"> <section className="p-4">
{event.message} <div className="border-0">
</p> <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 ReceiveViewProps = { type ReceiveViewProps = {
nostr: NostrType keys: NostrKeysType
event: NostrEventType
} }
export const ReceiveView = ({ nostr, event }: ReceiveViewProps) => { export const ReceiveView = ({ keys }: ReceiveViewProps) => {
const [peerKey, setPeerKey] = useState("")
const [message, setMessage] = useState("")
const events = useRef<{ [k: string]: NostrEventType } | null>(null)
const nostr = useRef<NostrType | null>(null)
const processEvent = (event: NostrEventType) => {
events.current = { ...events.current, ...{ [event.id]: event } }
setMessage(getLatestEvent(events?.current)?.message || "")
setPeerKey(getReceivePeerKey(events.current) || "")
}
useEffect(() => {
void (async () => {
const sub = await subscribe(keys, peerKey, processEvent)
nostr.current = { ...sub, ...keys }
return () => {
nostr?.current?.sub?.unsub()
}
})()
}, [peerKey])
const sendMessage = useRef(
// eslint-disable-next-line @typescript-eslint/no-misused-promises
debounce(async (peerKey: string, message: string) => {
if (nostr?.current?.pool) {
await sendEncryptedMessage({ ...nostr.current, peerKey, message })
}
}, 500),
).current
const onMessageChange = (message: string) => {
setMessage(message)
sendMessage(peerKey, message)
}
return ( return (
<div className="max-w-[64rem] m-auto"> <div className="max-w-[64rem] m-auto">
<Card> <Card>
<div className="p-10"> <div className="p-10">
<div className="flex flex-col lg:flex-row"> <div className="flex flex-col lg:flex-row">
{!event && ( {peerKey === "" && (
<div className="overflow-visible py-5 max-w-[20rem] mx-auto lg:pr-5"> <div className="overflow-visible py-5 max-w-[20rem] mx-auto lg:pr-5">
<QRCodeSVG <QRCodeSVG
value={nostr.pub} value={keys.pub}
level="H" level="H"
bgColor="transparent" bgColor="transparent"
fgColor="#3C3744" fgColor="#3C3744"
@@ -43,12 +91,12 @@ export const ReceiveView = ({ nostr, event }: ReceiveViewProps) => {
id="mypubkey" id="mypubkey"
className="border-2 border-custom-black rounded-md p-2 bg-custom-green-dark break-all" className="border-2 border-custom-black rounded-md p-2 bg-custom-green-dark break-all"
> >
{nostr.pub} {keys.pub}
</div> </div>
<div className="py-6 max-w-[20rem] m-auto"> <div className="py-6 max-w-[20rem] m-auto">
<Button <Button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(nostr.pub).catch(console.warn) navigator.clipboard.writeText(keys.pub).catch(console.warn)
Toastify({ Toastify({
text: "Pubkey copied", text: "Pubkey copied",
duration: 2000, duration: 2000,
@@ -67,7 +115,7 @@ export const ReceiveView = ({ nostr, event }: ReceiveViewProps) => {
</div> </div>
</div> </div>
</div> </div>
<div>{event && <Message event={event} />}</div> <div>{<Message message={message} onChange={onMessageChange} />}</div>
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -7,7 +7,8 @@ import { MdQrCodeScanner } from "react-icons/md"
import { IconButton } from "../../components/icon-button" import { IconButton } from "../../components/icon-button"
import { Card } from "../../components/card" import { Card } from "../../components/card"
import { debounce } from "../../lib/utils" import { debounce } from "../../lib/utils"
import { NostrType } from "../../types" import { NostrEventType, NostrKeysType, NostrType } from "../../types"
import { subscribe, sendEncryptedMessage, getLatestEvent } from "../../lib/nostr"
type MessageProps = { type MessageProps = {
message: string message: string
@@ -55,43 +56,48 @@ const PeerInput = ({ peerKey, onChange, setShowScan }: PeerInputProps) => {
} }
export type SendViewProps = { export type SendViewProps = {
nostr: NostrType keys: NostrKeysType
} }
export const SendView = ({ nostr }: SendViewProps) => { export const SendView = ({ keys }: SendViewProps) => {
const [showScan, setShowScan] = useState(false) const [showScan, setShowScan] = useState(false)
const [peerKey, setPeerKey] = useState("") const [peerKey, setPeerKey] = useState("")
const [message, setMessage] = useState("") const [message, setMessage] = useState("")
const events = useRef<{ [k: string]: NostrEventType } | null>(null)
const nostr = useRef<NostrType | null>(null)
const ScanView = dynamic(async () => (await import("../scan")).ScanView) const ScanView = dynamic(async () => (await import("../scan")).ScanView)
const processEvent = (event: NostrEventType) => {
events.current = { ...events.current, ...{ [event.id]: event } }
setMessage(getLatestEvent(events?.current)?.message || "")
}
useEffect(() => { useEffect(() => {
if (isMobile) { void (async () => {
setShowScan(true) if (isMobile) {
} setShowScan(true)
}
const sub = await subscribe(keys, peerKey, processEvent)
nostr.current = { ...sub, ...keys }
return () => {
nostr?.current?.sub?.unsub()
}
})()
}, []) }, [])
const sendNostrMessage = useRef( const sendMessage = useRef(
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
debounce(async (m: string, p: string, n: NostrType) => { debounce(async (peerKey: string, message: string) => {
try { if (nostr.current?.pool) {
const { encrypt } = await import("nostr-tools/nip04") await sendEncryptedMessage({ ...nostr.current, peerKey, message })
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), }, 500),
).current ).current
const sendMessage = (message: string) => { const onMessageChange = (message: string) => {
setMessage(message) setMessage(message)
sendNostrMessage(message, peerKey, nostr) sendMessage(peerKey, message)
} }
return ( return (
@@ -109,7 +115,7 @@ export const SendView = ({ nostr }: SendViewProps) => {
<Card> <Card>
<div className="p-4"> <div className="p-4">
<PeerInput peerKey={peerKey} setShowScan={setShowScan} onChange={setPeerKey} /> <PeerInput peerKey={peerKey} setShowScan={setShowScan} onChange={setPeerKey} />
{isValidPeerKey(peerKey) && <Message message={message} onChange={sendMessage} />} {isValidPeerKey(peerKey) && <Message message={message} onChange={onMessageChange} />}
</div> </div>
</Card> </Card>
</div> </div>