SkateSpots/src/hooks/useCordnChat.ts
shakespeare.diy 50d8d9e614 Wire live Cordn encrypted crew chat over the coordinator (ContextVM)
The crew coordinator is no longer just a pointer — when a crew configures one,
the Chat tab runs through it for real.

- src/lib/cordn: clean transport/crypto split so an MLS (ts-mls) upgrade is a
  drop-in swap of the crypto layer only.
  - types.ts: CordnMessage/CordnEndpoint + envelope kind 4497 + app tag.
  - crypto.ts: sealGroupMessage/openGroupMessage — real NIP-44 group encryption
    (per-recipient sealing), the exact seam MLS encrypt-to-epoch slots into.
  - client.ts: buildEnvelope/publishEnvelope/fetchGroupMessages with per-group
    cursor ordering and ciphertext→author sender binding (anti-spoof).
  - endpoint.ts: resolve a crew's `coordinator` to a usable relay endpoint
    (wss/ws direct; https falls back to the ContextVM relay).
- useCordnChat: fetch-then-poll hook mirroring the NIP-17 chat API.
- CrewChat: uses Cordn when the crew has a coordinator, NIP-17 fallback
  otherwise; header badge shows the active transport (Cordn vs Private).
- CrewCoordinatorCard: reflects that private chat is live.
- NIP.md: document envelope kind 4497, delivery (fetch-then-poll + cursors),
  the nip44-group sealed content, and the honest crypto→MLS seam.

Real encryption, real coordinator routing, non-breaking (fallback intact),
with the MLS ratchet as a clearly-marked next step.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 00:15:22 +07:00

95 lines
3.3 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 } 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, NostrSigner } 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;
}
/**
* 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;
const query = useQuery({
queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
enabled: active,
refetchInterval: active ? 5000 : false,
staleTime: 2000,
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) => {
const body = text.trim();
if (!body) return;
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);
const envelope: NostrEvent = await buildEnvelope(
user.signer as unknown as NostrSigner,
endpoint,
JSON.stringify(sealed),
);
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
await publishEnvelope(relay, envelope, AbortSignal.timeout(15000));
},
onSuccess: () => {
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,
};
}