/ concept-collection / trystero-messaging-demo
Sign in
concept-collection / trystero-messaging-demo
trystero-messaging-demo / src / useRoom.ts
310 lines · 8.6 KBCodeBlameHistory
efc2f43Trystero P2P messaging demo (text + large binary over Nostr)Jeremy Magland 1import {useCallback, useEffect, useRef, useState} from 'react'
2import {joinRoom, selfId, getRelaySockets} from 'trystero'
3import type {MessageAction} from 'trystero'
4import {APP_ID} from './config'
5import type {BinaryMeta, LogEntry, Peer, Transfer} from './types'
6import {sha256Hex, shortId, uid} from './util'
8const NAME_STORAGE_KEY = 'trystero-demo:name'
9const MAX_LOG = 200
11const loadName = (): string =>
12 localStorage.getItem(NAME_STORAGE_KEY) || `User-${selfId.slice(0, 4)}`
14export type RoomApi = {
15 selfId: string
16 selfName: string
17 setSelfName: (name: string) => void
18 peers: Peer[] // includes self (self is always first)
19 others: Peer[] // everyone except self
20 log: LogEntry[]
21 transfers: Transfer[]
22 relayCount: number
23 sendText: (text: string, target: string | null) => void
24 sendBinary: (
25 data: Uint8Array<ArrayBuffer>,
26 fileName: string,
27 mime: string,
28 target: string | null
29 ) => Promise<void>
30 clearLog: () => void
33type Actions = {
34 name: MessageAction<string>
35 chat: MessageAction<string>
36 binary: MessageAction
39// Normalize whatever a Trystero binary action hands us into an ArrayBuffer-backed
40// Uint8Array. (Binary payloads arrive as raw ArrayBuffers, but we guard for
41// views too.)
42const toBytes = (data: unknown): Uint8Array<ArrayBuffer> => {
43 if (data instanceof ArrayBuffer) return new Uint8Array(data)
44 if (ArrayBuffer.isView(data)) {
45 const v = data as ArrayBufferView
46 const out = new Uint8Array(v.byteLength)
47 out.set(new Uint8Array(v.buffer, v.byteOffset, v.byteLength))
48 return out
49 }
50 return new Uint8Array(0)
53export function useRoom(roomId: string): RoomApi {
54 const [selfName, setSelfNameState] = useState<string>(loadName)
55 const [peerNames, setPeerNames] = useState<Record<string, string>>({})
56 const [log, setLog] = useState<LogEntry[]>([])
57 const [transfers, setTransfers] = useState<Transfer[]>([])
58 const [relayCount, setRelayCount] = useState(0)
60 // Action callbacks registered with Trystero are long-lived, so they must read
61 // the *latest* name map / self name through refs to avoid stale closures.
62 const selfNameRef = useRef(selfName)
63 selfNameRef.current = selfName
64 const peerNamesRef = useRef(peerNames)
65 peerNamesRef.current = peerNames
67 const actionsRef = useRef<Actions | null>(null)
69 const nameOf = useCallback(
70 (id: string): string => peerNamesRef.current[id] || `(${shortId(id)})`,
71 []
72 )
74 const pushLog = useCallback((entry: Omit<LogEntry, 'key' | 'time'>) => {
75 setLog(prev =>
76 [...prev, {...entry, key: uid(), time: Date.now()}].slice(-MAX_LOG)
77 )
78 }, [])
80 // ---- join / leave the room whenever roomId changes -------------------------
81 useEffect(() => {
82 setPeerNames({})
83 setTransfers([])
85 const room = joinRoom({appId: APP_ID}, roomId)
87 const name = room.makeAction<string>('name')
88 const chat = room.makeAction<string>('chat')
89 const binary = room.makeAction('binary')
90 actionsRef.current = {name, chat, binary}
92 pushLog({kind: 'system', dir: 'sys', text: `Joined room "${roomId}"`})
94 room.onPeerJoin = peerId => {
95 // show the peer immediately (name fills in once their `name` msg arrives)
96 setPeerNames(prev =>
97 peerId in prev ? prev : {...prev, [peerId]: ''}
98 )
99 pushLog({
100 kind: 'system',
101 dir: 'sys',
102 text: `${shortId(peerId)} connected`
103 })
104 // greet the newcomer with our current display name
105 name.send(selfNameRef.current, {target: peerId})
106 }
108 room.onPeerLeave = peerId => {
109 pushLog({
110 kind: 'system',
111 dir: 'sys',
112 text: `${nameOf(peerId)} disconnected`
113 })
114 setPeerNames(prev => {
115 const next = {...prev}
116 delete next[peerId]
117 return next
118 })
119 }
121 name.onMessage = (value, {peerId}) => {
122 setPeerNames(prev => ({...prev, [peerId]: String(value)}))
123 }
125 chat.onMessage = (text, {peerId}) => {
126 pushLog({
127 kind: 'text',
128 dir: 'in',
129 peerId,
130 peerName: nameOf(peerId),
131 text: String(text)
132 })
133 }
135 // receiver-side progress for incoming binary
136 binary.onReceiveProgress = (percent, {peerId, metadata}) => {
137 const meta = metadata as BinaryMeta | undefined
138 if (!meta) return
139 setTransfers(prev => upsertTransfer(prev, {
140 id: meta.transferId,
141 dir: 'in',
142 peerId,
143 peerName: nameOf(peerId),
144 fileName: meta.fileName,
145 size: meta.size,
146 percent,
147 done: false
148 }))
149 }
151 // full binary payload received
152 binary.onMessage = async (data, {peerId, metadata}) => {
153 const meta = metadata as BinaryMeta | undefined
154 const bytes = toBytes(data)
155 const hash = await sha256Hex(bytes)
156 const blob = new Blob([bytes], {
157 type: meta?.mime || 'application/octet-stream'
158 })
159 const blobUrl = URL.createObjectURL(blob)
160 const transferId = meta?.transferId ?? uid()
161 setTransfers(prev =>
162 prev.map(t =>
163 t.id === transferId ? {...t, percent: 1, done: true} : t
164 )
165 )
166 pushLog({
167 kind: 'binary',
168 dir: 'in',
169 peerId,
170 peerName: nameOf(peerId),
171 fileName: meta?.fileName ?? 'payload.bin',
172 size: bytes.byteLength,
173 sha256: hash,
174 blobUrl
175 })
176 }
178 return () => {
179 actionsRef.current = null
180 room.leave()
181 }
182 // nameOf / pushLog are stable (useCallback []), roomId drives re-join
183 // eslint-disable-next-line react-hooks/exhaustive-deps
184 }, [roomId])
186 // ---- poll relay connection count (nostr signalling backend) ----------------
187 useEffect(() => {
188 const tick = () => {
189 try {
190 setRelayCount(Object.keys(getRelaySockets()).length)
191 } catch {
192 setRelayCount(0)
193 }
194 }
195 tick()
196 const interval = setInterval(tick, 2000)
197 return () => clearInterval(interval)
198 }, [])
200 const setSelfName = useCallback((name: string) => {
201 const trimmed = name.trim() || `User-${selfId.slice(0, 4)}`
202 setSelfNameState(trimmed)
203 localStorage.setItem(NAME_STORAGE_KEY, trimmed)
204 actionsRef.current?.name.send(trimmed) // broadcast to everyone
205 }, [])
207 const sendText = useCallback(
208 (text: string, target: string | null) => {
209 const actions = actionsRef.current
210 if (!actions || !text) return
211 actions.chat.send(text, target ? {target} : undefined)
212 pushLog({
213 kind: 'text',
214 dir: 'out',
215 peerId: target ?? undefined,
216 peerName: target ? nameOf(target) : 'everyone',
217 text
218 })
219 },
220 [nameOf, pushLog]
221 )
223 const sendBinary = useCallback(
224 async (
225 data: Uint8Array<ArrayBuffer>,
226 fileName: string,
227 mime: string,
228 target: string | null
229 ) => {
230 const actions = actionsRef.current
231 if (!actions) return
232 const transferId = uid()
233 const meta: BinaryMeta = {
234 transferId,
235 fileName,
236 mime,
237 size: data.byteLength
238 }
239 const hash = await sha256Hex(data)
241 setTransfers(prev => [
242 ...prev,
243 {
244 id: transferId,
245 dir: 'out',
246 peerId: target ?? 'everyone',
247 peerName: target ? nameOf(target) : 'everyone',
248 fileName,
249 size: data.byteLength,
250 percent: 0,
251 done: false
252 }
253 ])
255 await actions.binary.send(data, {
256 target: target ?? undefined,
257 metadata: meta,
258 onProgress: percent =>
259 setTransfers(prev =>
260 prev.map(t => (t.id === transferId ? {...t, percent} : t))
261 )
262 })
264 setTransfers(prev =>
265 prev.map(t => (t.id === transferId ? {...t, percent: 1, done: true} : t))
266 )
267 pushLog({
268 kind: 'binary',
269 dir: 'out',
270 peerId: target ?? undefined,
271 peerName: target ? nameOf(target) : 'everyone',
272 fileName,
273 size: data.byteLength,
274 sha256: hash
275 })
276 },
277 [nameOf, pushLog]
278 )
280 const clearLog = useCallback(() => setLog([]), [])
282 const others: Peer[] = Object.keys(peerNames)
283 .sort()
284 .map(id => ({id, name: peerNames[id] || `(${shortId(id)})`, self: false}))
285 const peers: Peer[] = [{id: selfId, name: selfName, self: true}, ...others]
287 return {
288 selfId,
289 selfName,
290 setSelfName,
291 peers,
292 others,
293 log,
294 transfers,
295 relayCount,
296 sendText,
297 sendBinary,
298 clearLog
299 }
302// Insert or update an incoming transfer entry by id.
303function upsertTransfer(prev: Transfer[], next: Transfer): Transfer[] {
304 const idx = prev.findIndex(t => t.id === next.id)
305 if (idx === -1) return [...prev, next]
306 const copy = prev.slice()
307 // keep `done` sticky once set
308 copy[idx] = {...copy[idx], ...next, done: copy[idx].done || next.done}
309 return copy
moveopenescclose