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>
95 lines
3.3 KiB
TypeScript
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,
|
|
};
|
|
}
|