import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { buildEnvelope, fetchGroupMessages, publishEnvelope, type CordnRelay, } from '@/lib/cordn/client'; import { sealGroupMessage, type Nip44Signer } from '@/lib/cordn/crypto'; import type { CordnEndpoint, CordnMessage } from '@/lib/cordn/types'; import type { NostrEvent } from '@nostrify/nostrify'; interface UseCordnChatParams { /** Resolved coordinator endpoint (relay + group id). `null` disables the chat. */ endpoint: CordnEndpoint | null; /** Full roster (founder + members) — recipients for group encryption. */ roster: string[]; /** Whether the viewer is a member (only members query/decrypt). */ enabled: boolean; } /** * ⚠️ UNWIRED (2026-10-13). The crew **Chat** tab is now always NIP-17 (`CrewChat`); * coordinator routing lives in the crew's **Private chat** (MLS). This sealed-relay * transport (pre-MLS NIP-44 group delivery over the coordinator's relay) is kept as * a reference but is no longer rendered — see `CREW_CHAT_AUDIT.md`. Re-wiring it is * a deliberate, separate decision (it split chat history per transport). * * Crew encrypted group chat over a Cordn coordinator. * * Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live * delivery (5s) — robust in a browser PWA and coordinator-agnostic. Mirrors the * NIP-17 `useGroupConversation` / `useSendDirectMessage` API so `CrewChat` can * swap transports with no UI change. */ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const queryClient = useQueryClient(); const canEncrypt = checkNip44(user, logins); const active = !!endpoint && !!user && enabled && canEncrypt; // Amber prompts on every message decrypt. A 5s live poll would fire an Amber // popup every 5 seconds — unusable. For Amber we disable the poll (the user // refreshes/sends to pull new messages) and stop focus refetches; silent // signers keep the live 5s poll. const interactive = isInteractiveSigner(user, logins); const query = useQuery({ queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey], enabled: active, refetchInterval: active && !interactive ? 5000 : false, refetchOnWindowFocus: !interactive, staleTime: interactive ? Infinity : 2000, // Amber: decrypt the backlog once per session, not on every crew-page visit. ...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}), queryFn: async (): Promise => { if (!endpoint || !user) return []; const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; const signer = user.signer as unknown as Nip44Signer; const signal = AbortSignal.timeout(10000); return fetchGroupMessages({ relay, endpoint, signer, viewerPk: user.pubkey, signal, }); }, }); const send = useMutation({ mutationFn: async (text: string): Promise => { const body = text.trim(); if (!body) return null; if (!endpoint || !user) throw new Error('Crew chat is not available.'); if (!canEncrypt) throw new Error('Your signer does not support NIP-44 encryption.'); const signer = user.signer as unknown as Nip44Signer; const sealed = await sealGroupMessage(signer, user.pubkey, roster, body, endpoint.groupId); // Envelope is signed with a throwaway ephemeral key inside buildEnvelope — // the coordinator never sees the sender. Authorship lives in the sealed // v2 author proof instead. const envelope: NostrEvent = await buildEnvelope(endpoint, JSON.stringify(sealed)); const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; await publishEnvelope(relay, envelope, AbortSignal.timeout(15000)); // Show the sender's own message immediately (see onSuccess). return { id: envelope.id, pubkey: user.pubkey, content: body, createdAt: envelope.created_at, cursor: Date.now(), }; }, onSuccess: (optimistic) => { // Seed our own message into the cache so it appears without a refetch — // a refetch would re-decrypt the whole backlog (a pop-up storm on Amber). if (optimistic) { queryClient.setQueryData( ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey], (old) => { const list = old ?? []; if (list.some((m) => m.id === optimistic.id)) return list; return [...list, optimistic].sort((a, b) => a.cursor - b.cursor); }, ); } // Silent signers can afford the refetch; interactive ones must not. if (!interactive) { queryClient.invalidateQueries({ queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey], }); } }, }); return { messages: query.data ?? [], isLoading: query.isLoading, isError: query.isError, canEncrypt, send: send.mutateAsync, isSending: send.isPending, }; }