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; /** 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 pubkey) and deliver a welcome. */ approve: (identifier: string) => 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; /** Re-open the session after a connection error (the Retry button). */ retry: () => void; } const POLL_MS = 6000; /** * 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 [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); }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); }); 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 () => { const session = sessionRef.current; if (!session || !group) return; 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); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, [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). useEffect(() => { if (!ready || !group || interactive || !canBackgroundPoll) return; const id = setInterval(() => void refresh(), POLL_MS); return () => clearInterval(id); }, [ready, group, interactive, canBackgroundPoll, refresh]); const run = useCallback(async (fn: () => Promise): Promise => { setBusy(true); setError(null); try { return await fn(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); return undefined; } finally { setBusy(false); } }, []); 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 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) => { const session = sessionRef.current; if (!session || !group) return false; const approved = await run(async () => { await session.addMemberByIdentifier(group.gid, identifier); const pending = await session.fetchJoinRequests(group.gid); setRequests(pending); return true; }); return approved === true; }, [group, run], ); return { ready, coordinator, groupId, group, messages, busy, error, send, refresh, createChat, requestJoin, checkInvite, requests, loadRequests, loadRequestsQuiet, approve, removeMember, createDeviceLink, addDevice, interactiveHintVisible: interactive, passiveSigner, groupRef, keyPackageReady, 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; }