import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNostrLogin } from '@nostrify/react/login'; import type { NostrSigner } from '@contextvm/sdk/core'; import type { JoinRequest } from '@cordn/core'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCoordinator } from '@/hooks/useCoordinator'; import { SKATESPOTS_COORDINATOR } from '@/lib/coordinator'; import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; // Dexie-only (light); the heavy MLS/ContextVM module is imported lazily so it // loads only when the private-chat tab is opened, and stays out of the crew-page // chunk. import { createCordnStore, cordnGroupExists, cordnGroupGids } from '@/lib/cordn/native/idbStore'; import type { CordnChatMessage, CordnGroupInfo, CordnSession, } from '@/lib/cordn/native'; import { isInteractiveSigner, isPassiveSigner } from '@/lib/signerCapabilities'; import type { CordnRoom } from '@/lib/cordn/room'; import { decodeDeviceLink, encodeDeviceLink } from '@/lib/cordn/deviceLink'; /** Resolved coordinator endpoint for the native (ContextVM) client. */ export interface NativeCoordinator { serverPubkey: string; relays: string[]; } /** * Resolve the coordinator a crew's native MLS chat should use: the crew's own * `coordinator` tag when it names a service pubkey, otherwise the viewer's * Settings → Coordinator, otherwise SkateSpots' provided coordinator. */ export function resolveNativeCoordinator( crew: CordnRoom, fallback: { pubkey: string; relays: string[] } | null, ): NativeCoordinator | null { const endpoint = resolveCordnEndpoint(crew); const serverPubkey = endpoint?.coordinatorPubkey ?? fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey; if (!serverPubkey) return null; const relays = endpoint ? [endpoint.relayUrl] : fallback?.relays?.length ? fallback.relays : SKATESPOTS_COORDINATOR.relays; return { serverPubkey, relays }; } export interface UseCordnNativeResult { ready: boolean; coordinator: NativeCoordinator | null; /** The crew's MLS group id — the crew coordinate (deterministic for everyone). */ groupId: string; group: CordnGroupInfo | null; messages: CordnChatMessage[]; busy: boolean; error: string | null; /** * Send a group message (optionally an image URL, encrypted in the payload). * Returns the stored message on success, or `null` when the send failed. */ send: (text: string, imageUrl?: string) => Promise; /** Visible messages, with control messages (reaction/delete/read) filtered out. */ reactions: Record>; /** Message id → pubkeys that sent a read receipt. */ readBy: Record; /** Toggle a reaction for this viewer. */ toggleReaction: (messageId: string, emoji: string) => Promise; /** Delete-for-everyone (an MLS control tombstone; not a server hard delete). */ deleteMessage: (messageId: string) => Promise; /** Send a read receipt for an inbound message. */ markRead: (messageId: string) => Promise; /** Re-fetch messages (and adopt any new commits). */ refresh: () => Promise; /** Founder/admin: create the crew's MLS group. */ createChat: () => Promise; /** Non-admin: ask the coordinator to be added (publishes a key package). */ requestJoin: () => Promise; /** Non-admin: collect a waiting welcome and join. */ checkInvite: () => Promise; /** Founder/admin: pending join requests for this group. */ requests: JoinRequest[]; loadRequests: () => Promise; /** Background check that never toggles `busy` (for a tab/request badge). */ loadRequestsQuiet: () => Promise; /** Founder/admin: add a requester by key-package ref, falling back to pubkey. */ approve: ( identifier: string, fallbackIdentifier?: string, handled?: { pk: string; at: number }, ) => Promise; /** Founder/admin: remove a member leaf (device) from the group. */ removeMember: (leafIndex: number) => Promise; /** New device: publish a key package and return the QR payload. */ createDeviceLink: () => Promise; /** Existing member: scan a new-device payload and add it. */ addDevice: (raw: string) => Promise; /** True for interactive signers (Amber) — manual refresh/send, no polling. */ interactiveHintVisible: boolean; /** True for nsec/bunker: safe to poll while the private tab is hidden. */ passiveSigner: boolean; /** Shareable `cordn1…` reference for the crew's group (once known). */ groupRef: string | null; /** True when this device is set up: it holds the group or a published key package. */ keyPackageReady: boolean; /** Last completed coordinator/session check, or null before the first one. */ lastCheckedAt: number | null; /** Re-open the session after a connection error (the Retry button). */ retry: () => void; } const POLL_MS = 6000; const HIDDEN_POLL_MS = 12000; const MAX_BACKOFF_MS = 60000; /** * Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn * coordinator, keyed to the viewer's own Nostr identity (the transport signs * with the viewer's signer). State is persisted per account (IndexedDB), so a * reload resumes the same MLS group. */ export function useCordnNative({ crew, enabled, active = true, }: { crew: CordnRoom | null; enabled: boolean; /** Visible private-chat tab; hidden tabs only poll for passive signers. */ active?: boolean; }): UseCordnNativeResult { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { coordinator: userCoordinator } = useCoordinator(); const coordinator = useMemo( () => crew ? resolveNativeCoordinator( crew, userCoordinator ? { pubkey: userCoordinator.pubkey, relays: userCoordinator.relays } : null, ) : null, [crew, userCoordinator], ); const groupId = crew ? (crew.gid ?? `35006:${crew.pubkey}:${crew.identifier}`) : ''; const sessionRef = useRef(null); const [ready, setReady] = useState(false); const [group, setGroup] = useState(null); const [messages, setMessages] = useState([]); const [requests, setRequests] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [groupRef, setGroupRef] = useState(null); const [keyPackageReady, setKeyPackageReady] = useState(false); const [lastCheckedAt, setLastCheckedAt] = useState(null); const [reloadKey, setReloadKey] = useState(0); const interactive = isInteractiveSigner(user, logins); const passiveSigner = isPassiveSigner(user, logins); const canBackgroundPoll = active || passiveSigner; const retry = useCallback(() => { setError(null); setReady(false); setReloadKey((n) => n + 1); }, []); // Stable primitives for the session effect — depending on the `user` object // itself would re-open the session on every render if its identity churns. const pubkey = user?.pubkey ?? null; const signer = user?.signer; // Open the session when the crew, coordinator and identity are available. useEffect(() => { if (!enabled || !pubkey || !signer || !coordinator || !groupId) return; let cancelled = false; setError(null); const session = import('@/lib/cordn/native').then((mod) => { if (!cancelled) { try { setGroupRef( mod.encodeGroupRef({ gid: groupId, coordinatorPubkey: coordinator.serverPubkey, relays: coordinator.relays, }), ); } catch { setGroupRef(null); } } return mod.CordnSession.open({ coordinator: { serverPubkey: coordinator.serverPubkey, relays: coordinator.relays }, pubkey, signer: signer as unknown as NostrSigner, store: createCordnStore(pubkey), }); }); session .then((opened) => { if (cancelled) { void opened.disconnect(); return; } sessionRef.current = opened; const found = opened.listGroups().find((g) => g.gid === groupId) ?? null; setGroup(found); setMessages(found ? opened.messages(found.gid) : []); setKeyPackageReady( !!found || opened.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed), ); setReady(true); setLastCheckedAt(Date.now()); }) .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : String(err)); setLastCheckedAt(Date.now()); } }); return () => { cancelled = true; const s = sessionRef.current; sessionRef.current = null; setReady(false); setGroupRef(null); setKeyPackageReady(false); if (s) void s.disconnect(); }; }, [enabled, pubkey, signer, coordinator, groupId, reloadKey]); const refresh = useCallback(async (): Promise => { const session = sessionRef.current; if (!session || !group) return false; try { await session.fetch(group.gid); const next = session.messages(group.gid); setMessages(next); setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group); setError(null); setLastCheckedAt(Date.now()); return true; } catch (err) { setError(err instanceof Error ? err.message : String(err)); setLastCheckedAt(Date.now()); return false; } }, [group]); // Live polling for silent signers only — an interactive signer would pop for // every request, so those users refresh/send manually (same rule as the DM // and sealed-relay chat hooks). Hidden tabs poll less, and a failure backs // off exponentially with jitter so a busy/rate-limited coordinator is not // hammered by every open device. const pollDelayRef = useRef(POLL_MS); useEffect(() => { if (!ready || !group || interactive || !canBackgroundPoll) return; let cancelled = false; let timer: ReturnType | undefined; pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS; const tick = async () => { const ok = await refresh(); if (cancelled) return; if (ok) { pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS; } else { pollDelayRef.current = Math.min( MAX_BACKOFF_MS, Math.max(POLL_MS, Math.round(pollDelayRef.current * 1.7)), ); } const jitter = Math.round(Math.random() * 800); timer = setTimeout(() => void tick(), pollDelayRef.current + jitter); }; timer = setTimeout(() => void tick(), pollDelayRef.current); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [ready, group, interactive, canBackgroundPoll, active, refresh]); // One coordinator operation at a time. With Amber every coordinator request // is a foreground approval; a double-tap on "Set up" used to start two // publish/join flows, which looked like Amber randomly opening twice and left // one of the two requests to time out. const runInFlight = useRef(false); const run = useCallback(async (fn: () => Promise): Promise => { if (runInFlight.current) return undefined; runInFlight.current = true; setBusy(true); setError(null); try { return await fn(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); return undefined; } finally { runInFlight.current = false; setBusy(false); setLastCheckedAt(Date.now()); } }, []); const send = useCallback( async (text: string, imageUrl?: string) => { const body = text.trim(); const session = sessionRef.current; if ((!body && !imageUrl) || !session || !group) return null; const stored = await run(async () => { const message = await session.send(group.gid, body, imageUrl); setMessages(session.messages(group.gid)); return message; }); return stored ?? null; }, [group, run], ); const { visibleMessages, reactions, readBy } = useMemo(() => { const reactionSets: Record>> = {}; const readSets: Record> = {}; const deleted = new Set(); const controlIds = new Set(); for (const message of messages) { let isControl = false; for (const [name, first, second] of message.tags ?? []) { if (name === 'delete' && first) { deleted.add(first); isControl = true; } else if ((name === 'reaction' || name === 'unreact') && first && second) { reactionSets[second] ??= {}; reactionSets[second][first] ??= new Set(); if (name === 'reaction') reactionSets[second][first].add(message.sender); else reactionSets[second][first].delete(message.sender); isControl = true; } else if (name === 'read' && first) { readSets[first] ??= new Set(); readSets[first].add(message.sender); isControl = true; } } if (isControl && !message.content.trim() && !message.imageUrl) controlIds.add(message.id); } const reactionMap: Record> = {}; for (const [target, byEmoji] of Object.entries(reactionSets)) { reactionMap[target] = {}; for (const [emoji, senders] of Object.entries(byEmoji)) { if (senders.size > 0) reactionMap[target][emoji] = [...senders]; } } const readMap: Record = {}; for (const [target, senders] of Object.entries(readSets)) { if (senders.size > 0) readMap[target] = [...senders]; } return { visibleMessages: messages.filter((m) => !controlIds.has(m.id) && !deleted.has(m.id)), reactions: reactionMap, readBy: readMap, }; }, [messages]); const toggleReaction = useCallback( async (messageId: string, emoji: string) => { const session = sessionRef.current; if (!session || !group || !user?.pubkey) return false; const already = reactions[messageId]?.[emoji]?.includes(user.pubkey); const result = await run(async () => { await session.sendControl(group.gid, [[already ? 'unreact' : 'reaction', emoji, messageId]]); setMessages(session.messages(group.gid)); return true; }); return result === true; }, [group, reactions, run, user?.pubkey], ); const deleteMessage = useCallback( async (messageId: string) => { const session = sessionRef.current; if (!session || !group) return false; const result = await run(async () => { await session.sendControl(group.gid, [['delete', messageId]]); setMessages(session.messages(group.gid)); return true; }); return result === true; }, [group, run], ); const markRead = useCallback( async (messageId: string) => { const session = sessionRef.current; if (!session || !group) return false; try { await session.sendControl(group.gid, [['read', messageId]]); setMessages(session.messages(group.gid)); return true; } catch { return false; } }, [group], ); const createChat = useCallback(async () => { const session = sessionRef.current; if (!session || !crew || !groupId) return false; const created = await run(async () => { const groupInfo = await session.createGroup({ groupId, name: crew.name }); setGroup(groupInfo); setMessages(session.messages(groupInfo.gid)); return true; }); return created === true; }, [crew, groupId, run]); const requestJoin = useCallback(async () => { const session = sessionRef.current; if (!session || !groupId) return false; const requested = await run(async () => { await session.requestJoin(groupId); setKeyPackageReady( session.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed), ); return true; }); return requested === true; }, [groupId, run]); const checkInvite = useCallback(async () => { const session = sessionRef.current; if (!session || !crew || !groupId) return false; const joined = await run(async () => { const welcomes = await session.fetchWelcomes(); if (welcomes.length === 0) return false; // A signed-in account can have welcomes for several crews. Accept each // one (so it isn't processed again) but only adopt the group that belongs // to this crew — otherwise opening crew A could show crew B's chat. for (const welcome of welcomes) { try { const joinedGroup = await session.acceptWelcome(welcome, crew.name); if (joinedGroup.gid === groupId) { setGroup(joinedGroup); setMessages(session.messages(joinedGroup.gid)); setKeyPackageReady(true); return true; } } catch { // Welcome for a key package this device no longer has — skip it. } } return false; }); return joined ?? false; }, [crew, groupId, run]); const loadRequests = useCallback(async () => { const session = sessionRef.current; if (!session || !groupId) return; await run(async () => { const pending = await session.fetchJoinRequests(groupId); setRequests(pending); }); }, [groupId, run]); const loadRequestsQuiet = useCallback(async () => { const session = sessionRef.current; if (!session || !groupId) return; try { const pending = await session.fetchJoinRequests(groupId); setRequests(pending); } catch { // Keep the previous count; the next quiet poll or manual check retries. } }, [groupId]); const removeMember = useCallback( async (leafIndex: number) => { const session = sessionRef.current; if (!session || !group) return false; const removed = await run(async () => { const next = await session.removeMember(group.gid, leafIndex); setGroup(next); setMessages(session.messages(group.gid)); return true; }); return removed === true; }, [group, run], ); const createDeviceLink = useCallback(async () => { const session = sessionRef.current; if (!session || !groupId || !coordinator) return null; const code = await run(async () => { const keyPackage = await session.publishKeyPackage(`device-${Date.now()}`); return encodeDeviceLink({ gid: groupId, kpRef: keyPackage.keyPackageRef, pubkey: session.pubkey, coordinatorPubkey: coordinator.serverPubkey, relays: coordinator.relays, }); }); return code ?? null; }, [groupId, coordinator, run]); const addDevice = useCallback( async (raw: string) => { const session = sessionRef.current; const link = decodeDeviceLink(raw); if (!session || !group || !coordinator || !link) return false; if (link.gid !== groupId || link.coordinatorPubkey !== coordinator.serverPubkey) return false; const added = await run(async () => { try { await session.addMemberByIdentifier(group.gid, link.kpRef); } catch { await session.addMemberByIdentifier(group.gid, link.pubkey); } const next = session.listGroups().find((g) => g.gid === group.gid) ?? group; setGroup(next); setMessages(session.messages(group.gid)); return true; }); return added === true; }, [group, groupId, coordinator, run], ); const approve = useCallback( async (identifier: string, fallbackIdentifier?: string, handled?: { pk: string; at: number }) => { const session = sessionRef.current; if (!session || !group) return false; const approved = await run(async () => { // Prefer the requester's exact key package; older coordinators may only // resolve the pubkey, so keep that as a fallback. try { await session.addMemberByIdentifier(group.gid, identifier); } catch (error) { const message = error instanceof Error ? error.message : String(error); // A stale request for an account already in the group: retire it // instead of leaving it in the queue forever. if (/already in the group/i.test(message)) { if (handled) await session.fetchJoinRequests(group.gid, [handled]); setRequests((current) => current.filter((request) => request.pk !== handled?.pk)); return true; } if (!fallbackIdentifier || fallbackIdentifier === identifier) throw error; await session.addMemberByIdentifier(group.gid, fallbackIdentifier); } const pending = await session.fetchJoinRequests( group.gid, handled ? [handled] : [], ); setRequests(pending); return true; }); return approved === true; }, [group, run], ); return { ready, coordinator, groupId, group, messages: visibleMessages, reactions, readBy, busy, error, send, toggleReaction, deleteMessage, markRead, refresh, createChat, requestJoin, checkInvite, requests, loadRequests, loadRequestsQuiet, approve, removeMember, createDeviceLink, addDevice, interactiveHintVisible: interactive, passiveSigner, groupRef, keyPackageReady, lastCheckedAt, retry, }; } /** * Purely local check: does this account already have a saved MLS group for * `gid`? No coordinator request and no signer prompt — it reads the IndexedDB * snapshot. Re-runs when `refreshKey` changes. `undefined` until known. */ export function useCordnGroupExists( accountPubkey: string | undefined, gid: string, refreshKey?: unknown, ): boolean | undefined { const [exists, setExists] = useState(undefined); useEffect(() => { let cancelled = false; if (!accountPubkey || !gid) { setExists(undefined); return; } cordnGroupExists(accountPubkey, gid) .then((value) => { if (!cancelled) setExists(value); }) .catch(() => { if (!cancelled) setExists(false); }); return () => { cancelled = true; }; }, [accountPubkey, gid, refreshKey]); return exists; } /** * All MLS group ids this account holds (one IndexedDB read). Lets a list page * mark which crews already have a private chat without a per-crew read. */ export function useCordnGroupGids( accountPubkey: string | undefined, refreshKey?: unknown, ): Set | undefined { const [gids, setGids] = useState | undefined>(undefined); useEffect(() => { let cancelled = false; if (!accountPubkey) { setGids(undefined); return; } cordnGroupGids(accountPubkey) .then((value) => { if (!cancelled) setGids(value); }) .catch(() => { if (!cancelled) setGids(new Set()); }); return () => { cancelled = true; }; }, [accountPubkey, refreshKey]); return gids; }