Per the crew-chat audit recommendations: - The crew Chat tab no longer silently switches transport on a coordinator tag. It is always NIP-17; coordinator routing lives only in the Private chat (MLS). useCordnChat / useCrewChatUnread / lib/cordn client+crypto are kept but unwired (headers marked). Drops the tab's Cordn-only unread dot. - Group reads (Groups list + crew thread) widened 200 -> 500 wraps; the shared decrypt cache makes the extra reads affordable. 1:1 list stays 200.
132 lines
5.4 KiB
TypeScript
132 lines
5.4 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 (): Promise<CordnMessage[]> => {
|
|
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<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,
|
|
};
|
|
}
|