import {useCallback, useEffect, useRef, useState} from 'react' import {joinRoom, selfId, getRelaySockets} from 'trystero' import type {MessageAction} from 'trystero' import {APP_ID} from './config' import type {BinaryMeta, LogEntry, Peer, Transfer} from './types' import {sha256Hex, shortId, uid} from './util' const NAME_STORAGE_KEY = 'trystero-demo:name' const MAX_LOG = 200 const loadName = (): string => localStorage.getItem(NAME_STORAGE_KEY) || `User-${selfId.slice(0, 4)}` export type RoomApi = { selfId: string selfName: string setSelfName: (name: string) => void peers: Peer[] // includes self (self is always first) others: Peer[] // everyone except self log: LogEntry[] transfers: Transfer[] relayCount: number sendText: (text: string, target: string | null) => void sendBinary: ( data: Uint8Array, fileName: string, mime: string, target: string | null ) => Promise clearLog: () => void } type Actions = { name: MessageAction chat: MessageAction binary: MessageAction } // Normalize whatever a Trystero binary action hands us into an ArrayBuffer-backed // Uint8Array. (Binary payloads arrive as raw ArrayBuffers, but we guard for // views too.) const toBytes = (data: unknown): Uint8Array => { if (data instanceof ArrayBuffer) return new Uint8Array(data) if (ArrayBuffer.isView(data)) { const v = data as ArrayBufferView const out = new Uint8Array(v.byteLength) out.set(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)) return out } return new Uint8Array(0) } export function useRoom(roomId: string): RoomApi { const [selfName, setSelfNameState] = useState(loadName) const [peerNames, setPeerNames] = useState>({}) const [log, setLog] = useState([]) const [transfers, setTransfers] = useState([]) const [relayCount, setRelayCount] = useState(0) // Action callbacks registered with Trystero are long-lived, so they must read // the *latest* name map / self name through refs to avoid stale closures. const selfNameRef = useRef(selfName) selfNameRef.current = selfName const peerNamesRef = useRef(peerNames) peerNamesRef.current = peerNames const actionsRef = useRef(null) const nameOf = useCallback( (id: string): string => peerNamesRef.current[id] || `(${shortId(id)})`, [] ) const pushLog = useCallback((entry: Omit) => { setLog(prev => [...prev, {...entry, key: uid(), time: Date.now()}].slice(-MAX_LOG) ) }, []) // ---- join / leave the room whenever roomId changes ------------------------- useEffect(() => { setPeerNames({}) setTransfers([]) const room = joinRoom({appId: APP_ID}, roomId) const name = room.makeAction('name') const chat = room.makeAction('chat') const binary = room.makeAction('binary') actionsRef.current = {name, chat, binary} pushLog({kind: 'system', dir: 'sys', text: `Joined room "${roomId}"`}) room.onPeerJoin = peerId => { // show the peer immediately (name fills in once their `name` msg arrives) setPeerNames(prev => peerId in prev ? prev : {...prev, [peerId]: ''} ) pushLog({ kind: 'system', dir: 'sys', text: `${shortId(peerId)} connected` }) // greet the newcomer with our current display name name.send(selfNameRef.current, {target: peerId}) } room.onPeerLeave = peerId => { pushLog({ kind: 'system', dir: 'sys', text: `${nameOf(peerId)} disconnected` }) setPeerNames(prev => { const next = {...prev} delete next[peerId] return next }) } name.onMessage = (value, {peerId}) => { setPeerNames(prev => ({...prev, [peerId]: String(value)})) } chat.onMessage = (text, {peerId}) => { pushLog({ kind: 'text', dir: 'in', peerId, peerName: nameOf(peerId), text: String(text) }) } // receiver-side progress for incoming binary binary.onReceiveProgress = (percent, {peerId, metadata}) => { const meta = metadata as BinaryMeta | undefined if (!meta) return setTransfers(prev => upsertTransfer(prev, { id: meta.transferId, dir: 'in', peerId, peerName: nameOf(peerId), fileName: meta.fileName, size: meta.size, percent, done: false })) } // full binary payload received binary.onMessage = async (data, {peerId, metadata}) => { const meta = metadata as BinaryMeta | undefined const bytes = toBytes(data) const hash = await sha256Hex(bytes) const blob = new Blob([bytes], { type: meta?.mime || 'application/octet-stream' }) const blobUrl = URL.createObjectURL(blob) const transferId = meta?.transferId ?? uid() setTransfers(prev => prev.map(t => t.id === transferId ? {...t, percent: 1, done: true} : t ) ) pushLog({ kind: 'binary', dir: 'in', peerId, peerName: nameOf(peerId), fileName: meta?.fileName ?? 'payload.bin', size: bytes.byteLength, sha256: hash, blobUrl }) } return () => { actionsRef.current = null room.leave() } // nameOf / pushLog are stable (useCallback []), roomId drives re-join // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId]) // ---- poll relay connection count (nostr signalling backend) ---------------- useEffect(() => { const tick = () => { try { setRelayCount(Object.keys(getRelaySockets()).length) } catch { setRelayCount(0) } } tick() const interval = setInterval(tick, 2000) return () => clearInterval(interval) }, []) const setSelfName = useCallback((name: string) => { const trimmed = name.trim() || `User-${selfId.slice(0, 4)}` setSelfNameState(trimmed) localStorage.setItem(NAME_STORAGE_KEY, trimmed) actionsRef.current?.name.send(trimmed) // broadcast to everyone }, []) const sendText = useCallback( (text: string, target: string | null) => { const actions = actionsRef.current if (!actions || !text) return actions.chat.send(text, target ? {target} : undefined) pushLog({ kind: 'text', dir: 'out', peerId: target ?? undefined, peerName: target ? nameOf(target) : 'everyone', text }) }, [nameOf, pushLog] ) const sendBinary = useCallback( async ( data: Uint8Array, fileName: string, mime: string, target: string | null ) => { const actions = actionsRef.current if (!actions) return const transferId = uid() const meta: BinaryMeta = { transferId, fileName, mime, size: data.byteLength } const hash = await sha256Hex(data) setTransfers(prev => [ ...prev, { id: transferId, dir: 'out', peerId: target ?? 'everyone', peerName: target ? nameOf(target) : 'everyone', fileName, size: data.byteLength, percent: 0, done: false } ]) await actions.binary.send(data, { target: target ?? undefined, metadata: meta, onProgress: percent => setTransfers(prev => prev.map(t => (t.id === transferId ? {...t, percent} : t)) ) }) setTransfers(prev => prev.map(t => (t.id === transferId ? {...t, percent: 1, done: true} : t)) ) pushLog({ kind: 'binary', dir: 'out', peerId: target ?? undefined, peerName: target ? nameOf(target) : 'everyone', fileName, size: data.byteLength, sha256: hash }) }, [nameOf, pushLog] ) const clearLog = useCallback(() => setLog([]), []) const others: Peer[] = Object.keys(peerNames) .sort() .map(id => ({id, name: peerNames[id] || `(${shortId(id)})`, self: false})) const peers: Peer[] = [{id: selfId, name: selfName, self: true}, ...others] return { selfId, selfName, setSelfName, peers, others, log, transfers, relayCount, sendText, sendBinary, clearLog } } // Insert or update an incoming transfer entry by id. function upsertTransfer(prev: Transfer[], next: Transfer): Transfer[] { const idx = prev.findIndex(t => t.id === next.id) if (idx === -1) return [...prev, next] const copy = prev.slice() // keep `done` sticky once set copy[idx] = {...copy[idx], ...next, done: copy[idx].done || next.done} return copy }