SkateSpots/src/hooks/useCordnNative.ts
SkateSpots a9a5966714 Cordn native: browser probe, group-ref sharing, image messages
- scripts/cordn-browser-probe.mjs: headless-Chromium proof that the built
  native chunk runs in a real browser — imports the chunk, runs a session
  against the coordinator (publish kp, create group, send), then reopens from
  the Dexie/IndexedDB store. Not part of npm test (needs network + Chromium).
- Group ref: the panel shows/copies the group's cordn1... ref
  (encodeGroupRef from the crew coordinate + coordinator), so it can be opened
  in Staircase/cordn-web. Computed in the hook via the lazy native import.
- Images: attach button uploads to Blossom (useUploadFile) and sends the URL
  inside the encrypted MLS envelope (['image', url]); CordnChatMessage.imageUrl
  + ImageWithFallback rendering. Covered by the live test.
- Unread badge deliberately not built (needs a background signed request).

npm test + smoke + browser probe + 3 live scenarios all pass.
2026-10-12 22:42:44 +07:00

298 lines
9.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNostrLogin } from '@nostrify/react/login';
import type { NostrSigner } from '@contextvm/sdk/core';
import type { JoinRequest } from '@cordn/core';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCoordinator } from '@/hooks/useCoordinator';
import { SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
// Dexie-only (light); the heavy MLS/ContextVM module is imported lazily so it
// loads only when the private-chat tab is opened, and stays out of the crew-page
// chunk.
import { createCordnStore } from '@/lib/cordn/native/idbStore';
import type {
CordnChatMessage,
CordnGroupInfo,
CordnSession,
} from '@/lib/cordn/native';
import { isInteractiveSigner } from '@/lib/signerCapabilities';
import type { SkateCrew } from '@/lib/types';
/** Resolved coordinator endpoint for the native (ContextVM) client. */
export interface NativeCoordinator {
serverPubkey: string;
relays: string[];
}
/**
* Resolve the coordinator a crew's native MLS chat should use: the crew's own
* `coordinator` tag when it names a service pubkey, otherwise the viewer's
* Settings → Coordinator, otherwise SkateSpots' provided coordinator.
*/
export function resolveNativeCoordinator(
crew: SkateCrew,
fallback: { pubkey: string; relays: string[] } | null,
): NativeCoordinator | null {
const endpoint = resolveCordnEndpoint(crew);
const serverPubkey =
endpoint?.coordinatorPubkey ?? fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey;
if (!serverPubkey) return null;
const relays = endpoint
? [endpoint.relayUrl]
: fallback?.relays?.length
? fallback.relays
: SKATESPOTS_COORDINATOR.relays;
return { serverPubkey, relays };
}
export interface UseCordnNativeResult {
ready: boolean;
coordinator: NativeCoordinator | null;
/** The crew's MLS group id — the crew coordinate (deterministic for everyone). */
groupId: string;
group: CordnGroupInfo | null;
messages: CordnChatMessage[];
busy: boolean;
error: string | null;
/** Send a group message (optionally an image URL, encrypted in the payload). */
send: (text: string, imageUrl?: string) => Promise<void>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
/** Founder/admin: create the crew's MLS group. */
createChat: () => Promise<void>;
/** Non-admin: ask the coordinator to be added. */
requestJoin: () => Promise<void>;
/** Non-admin: collect a waiting welcome and join. */
checkInvite: () => Promise<boolean>;
/** Founder/admin: pending join requests for this group. */
requests: JoinRequest[];
loadRequests: () => Promise<void>;
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
approve: (identifier: string) => Promise<void>;
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
interactiveHintVisible: boolean;
/** Shareable `cordn1…` reference for the crew's group (once known). */
groupRef: string | null;
}
const POLL_MS = 6000;
/**
* Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn
* coordinator, keyed to the viewer's own Nostr identity (the transport signs
* with the viewer's signer). State is persisted per account (IndexedDB), so a
* reload resumes the same MLS group.
*/
export function useCordnNative({
crew,
enabled,
}: {
crew: SkateCrew | null;
enabled: boolean;
}): UseCordnNativeResult {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { coordinator: userCoordinator } = useCoordinator();
const coordinator = useMemo(
() =>
crew
? resolveNativeCoordinator(
crew,
userCoordinator ? { pubkey: userCoordinator.pubkey, relays: userCoordinator.relays } : null,
)
: null,
[crew, userCoordinator],
);
const groupId = crew ? `35006:${crew.pubkey}:${crew.identifier}` : '';
const sessionRef = useRef<CordnSession | null>(null);
const [ready, setReady] = useState(false);
const [group, setGroup] = useState<CordnGroupInfo | null>(null);
const [messages, setMessages] = useState<CordnChatMessage[]>([]);
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [groupRef, setGroupRef] = useState<string | null>(null);
const interactive = isInteractiveSigner(user, logins);
// Stable primitives for the session effect — depending on the `user` object
// itself would re-open the session on every render if its identity churns.
const pubkey = user?.pubkey ?? null;
const signer = user?.signer;
// Open the session when the crew, coordinator and identity are available.
useEffect(() => {
if (!enabled || !pubkey || !signer || !coordinator || !groupId) return;
let cancelled = false;
const session = import('@/lib/cordn/native').then((mod) => {
if (!cancelled) {
try {
setGroupRef(
mod.encodeGroupRef({
gid: groupId,
coordinatorPubkey: coordinator.serverPubkey,
relays: coordinator.relays,
}),
);
} catch {
setGroupRef(null);
}
}
return mod.CordnSession.open({
coordinator: { serverPubkey: coordinator.serverPubkey, relays: coordinator.relays },
pubkey,
signer: signer as unknown as NostrSigner,
store: createCordnStore(pubkey),
});
});
session
.then((opened) => {
if (cancelled) {
void opened.disconnect();
return;
}
sessionRef.current = opened;
const found = opened.listGroups().find((g) => g.gid === groupId) ?? null;
setGroup(found);
setMessages(found ? opened.messages(found.gid) : []);
setReady(true);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
const s = sessionRef.current;
sessionRef.current = null;
setReady(false);
setGroupRef(null);
if (s) void s.disconnect();
};
}, [enabled, pubkey, signer, coordinator, groupId]);
const refresh = useCallback(async () => {
const session = sessionRef.current;
if (!session || !group) return;
try {
await session.fetch(group.gid);
const next = session.messages(group.gid);
setMessages(next);
setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [group]);
// Live polling for silent signers only — an interactive signer would pop for
// every request, so those users refresh/send manually (same rule as the DM
// and sealed-relay chat hooks).
useEffect(() => {
if (!ready || !group || interactive) return;
const id = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(id);
}, [ready, group, interactive, refresh]);
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
setBusy(true);
setError(null);
try {
return await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return undefined;
} finally {
setBusy(false);
}
}, []);
const send = useCallback(
async (text: string, imageUrl?: string) => {
const body = text.trim();
const session = sessionRef.current;
if ((!body && !imageUrl) || !session || !group) return;
await run(async () => {
await session.send(group.gid, body, imageUrl);
setMessages(session.messages(group.gid));
});
},
[group, run],
);
const createChat = useCallback(async () => {
const session = sessionRef.current;
if (!session || !crew || !groupId) return;
await run(async () => {
const created = await session.createGroup({ groupId, name: crew.name });
setGroup(created);
setMessages(session.messages(created.gid));
});
}, [crew, groupId, run]);
const requestJoin = useCallback(async () => {
const session = sessionRef.current;
if (!session || !groupId) return;
await run(async () => {
await session.requestJoin(groupId);
});
}, [groupId, run]);
const checkInvite = useCallback(async () => {
const session = sessionRef.current;
if (!session || !crew || !groupId) return false;
const joined = await run(async () => {
const welcomes = await session.fetchWelcomes();
if (welcomes.length === 0) return false;
const joinedGroup = await session.acceptWelcome(welcomes[0], crew.name);
setGroup(joinedGroup);
setMessages(session.messages(joinedGroup.gid));
return true;
});
return joined ?? false;
}, [crew, groupId, run]);
const loadRequests = useCallback(async () => {
const session = sessionRef.current;
if (!session || !groupId) return;
await run(async () => {
const pending = await session.fetchJoinRequests(groupId);
setRequests(pending);
});
}, [groupId, run]);
const approve = useCallback(
async (identifier: string) => {
const session = sessionRef.current;
if (!session || !group) return;
await run(async () => {
await session.addMemberByIdentifier(group.gid, identifier);
const pending = await session.fetchJoinRequests(group.gid);
setRequests(pending);
});
},
[group, run],
);
return {
ready,
coordinator,
groupId,
group,
messages,
busy,
error,
send,
refresh,
createChat,
requestJoin,
checkInvite,
requests,
loadRequests,
approve,
interactiveHintVisible: interactive,
groupRef,
};
}