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 } from '@/lib/signerCapabilities'; import type { SkateCrew } from '@/lib/types'; /** 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: SkateCrew, 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). */ 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. */ requestJoin: () => Promise; /** Non-admin: collect a waiting welcome and join. */ checkInvite: () => Promise; /** Founder/admin: pending join requests for this group. */ requests: JoinRequest[]; loadRequests: () => Promise; /** Founder/admin: add a requester (by pubkey) and deliver a welcome. */ approve: (identifier: string) => Promise; /** True for interactive signers (Amber) — manual refresh/send, no polling. */ interactiveHintVisible: boolean; /** Shareable `cordn1…` reference for the crew's group (once known). */ groupRef: string | null; } 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, }: { crew: SkateCrew | null; enabled: 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 ? `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 interactive = isInteractiveSigner(user, logins); // 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; 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) : []); 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); if (s) void s.disconnect(); }; }, [enabled, pubkey, signer, coordinator, groupId]); 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) return; const id = setInterval(() => void refresh(), POLL_MS); return () => clearInterval(id); }, [ready, group, interactive, 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; await run(async () => { await session.send(group.gid, body, imageUrl); setMessages(session.messages(group.gid)); }); }, [group, run], ); const createChat = useCallback(async () => { const session = sessionRef.current; if (!session || !crew || !groupId) return; await run(async () => { const created = await session.createGroup({ groupId, name: crew.name }); setGroup(created); setMessages(session.messages(created.gid)); }); }, [crew, groupId, run]); const requestJoin = useCallback(async () => { const session = sessionRef.current; if (!session || !groupId) return; await run(async () => { await session.requestJoin(groupId); }); }, [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; const joinedGroup = await session.acceptWelcome(welcomes[0], crew.name); setGroup(joinedGroup); setMessages(session.messages(joinedGroup.gid)); return true; }); 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 approve = useCallback( async (identifier: string) => { const session = sessionRef.current; if (!session || !group) return; await run(async () => { await session.addMemberByIdentifier(group.gid, identifier); const pending = await session.fetchJoinRequests(group.gid); setRequests(pending); }); }, [group, run], ); return { ready, coordinator, groupId, group, messages, busy, error, send, refresh, createChat, requestJoin, checkInvite, requests, loadRequests, approve, interactiveHintVisible: interactive, groupRef, }; } /** * 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; }