SkateSpots/src/hooks/useCordnChat.ts

134 lines
5.6 KiB
TypeScript

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 (c): Promise<CordnMessage[]> => {
if (!endpoint || !user) return [];
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
const signer = user.signer as unknown as Nip44Signer;
// Honour query cancellation too — the fetch loop decrypts per message and
// must not keep opening Amber after the crew page is closed.
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
return fetchGroupMessages({
relay,
endpoint,
signer,
viewerPk: user.pubkey,
signal,
});
},
});
const send = useMutation({
mutationFn: async (text: string): Promise<CordnMessage | null> => {
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<CordnMessage[]>(
['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,
};
}