SkateSpots/src/hooks/useCordnNative.ts

490 lines
16 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, cordnGroupExists, cordnGroupGids } from '@/lib/cordn/native/idbStore';
import type {
CordnChatMessage,
CordnGroupInfo,
CordnSession,
} from '@/lib/cordn/native';
import { isInteractiveSigner, isPassiveSigner } from '@/lib/signerCapabilities';
import type { CordnRoom } from '@/lib/cordn/room';
import { decodeDeviceLink, encodeDeviceLink } from '@/lib/cordn/deviceLink';
/** 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: CordnRoom,
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).
* Returns the stored message on success, or `null` when the send failed.
*/
send: (text: string, imageUrl?: string) => Promise<CordnChatMessage | null>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
/** Founder/admin: create the crew's MLS group. */
createChat: () => Promise<boolean>;
/** Non-admin: ask the coordinator to be added (publishes a key package). */
requestJoin: () => Promise<boolean>;
/** Non-admin: collect a waiting welcome and join. */
checkInvite: () => Promise<boolean>;
/** Founder/admin: pending join requests for this group. */
requests: JoinRequest[];
loadRequests: () => Promise<void>;
/** Background check that never toggles `busy` (for a tab/request badge). */
loadRequestsQuiet: () => Promise<void>;
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
approve: (identifier: string) => Promise<boolean>;
/** Founder/admin: remove a member leaf (device) from the group. */
removeMember: (leafIndex: number) => Promise<boolean>;
/** New device: publish a key package and return the QR payload. */
createDeviceLink: () => Promise<string | null>;
/** Existing member: scan a new-device payload and add it. */
addDevice: (raw: string) => Promise<boolean>;
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
interactiveHintVisible: boolean;
/** True for nsec/bunker: safe to poll while the private tab is hidden. */
passiveSigner: boolean;
/** Shareable `cordn1…` reference for the crew's group (once known). */
groupRef: string | null;
/** True when this device is set up: it holds the group or a published key package. */
keyPackageReady: boolean;
/** Re-open the session after a connection error (the Retry button). */
retry: () => void;
}
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,
active = true,
}: {
crew: CordnRoom | null;
enabled: boolean;
/** Visible private-chat tab; hidden tabs only poll for passive signers. */
active?: 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 ? (crew.gid ?? `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 [keyPackageReady, setKeyPackageReady] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const interactive = isInteractiveSigner(user, logins);
const passiveSigner = isPassiveSigner(user, logins);
const canBackgroundPoll = active || passiveSigner;
const retry = useCallback(() => {
setError(null);
setReady(false);
setReloadKey((n) => n + 1);
}, []);
// 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;
setError(null);
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) : []);
setKeyPackageReady(
!!found ||
opened.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed),
);
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);
setKeyPackageReady(false);
if (s) void s.disconnect();
};
}, [enabled, pubkey, signer, coordinator, groupId, reloadKey]);
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 || !canBackgroundPoll) return;
const id = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(id);
}, [ready, group, interactive, canBackgroundPoll, 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 null;
const stored = await run(async () => {
const message = await session.send(group.gid, body, imageUrl);
setMessages(session.messages(group.gid));
return message;
});
return stored ?? null;
},
[group, run],
);
const createChat = useCallback(async () => {
const session = sessionRef.current;
if (!session || !crew || !groupId) return false;
const created = await run(async () => {
const groupInfo = await session.createGroup({ groupId, name: crew.name });
setGroup(groupInfo);
setMessages(session.messages(groupInfo.gid));
return true;
});
return created === true;
}, [crew, groupId, run]);
const requestJoin = useCallback(async () => {
const session = sessionRef.current;
if (!session || !groupId) return false;
const requested = await run(async () => {
await session.requestJoin(groupId);
setKeyPackageReady(
session.listKeyPackages().some((kp) => kp.publishedAt !== undefined && !kp.consumed),
);
return true;
});
return requested === true;
}, [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;
// A signed-in account can have welcomes for several crews. Accept each
// one (so it isn't processed again) but only adopt the group that belongs
// to this crew — otherwise opening crew A could show crew B's chat.
for (const welcome of welcomes) {
try {
const joinedGroup = await session.acceptWelcome(welcome, crew.name);
if (joinedGroup.gid === groupId) {
setGroup(joinedGroup);
setMessages(session.messages(joinedGroup.gid));
setKeyPackageReady(true);
return true;
}
} catch {
// Welcome for a key package this device no longer has — skip it.
}
}
return false;
});
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 loadRequestsQuiet = useCallback(async () => {
const session = sessionRef.current;
if (!session || !groupId) return;
try {
const pending = await session.fetchJoinRequests(groupId);
setRequests(pending);
} catch {
// Keep the previous count; the next quiet poll or manual check retries.
}
}, [groupId]);
const removeMember = useCallback(
async (leafIndex: number) => {
const session = sessionRef.current;
if (!session || !group) return false;
const removed = await run(async () => {
const next = await session.removeMember(group.gid, leafIndex);
setGroup(next);
setMessages(session.messages(group.gid));
return true;
});
return removed === true;
},
[group, run],
);
const createDeviceLink = useCallback(async () => {
const session = sessionRef.current;
if (!session || !groupId || !coordinator) return null;
const code = await run(async () => {
const keyPackage = await session.publishKeyPackage(`device-${Date.now()}`);
return encodeDeviceLink({
gid: groupId,
kpRef: keyPackage.keyPackageRef,
pubkey: session.pubkey,
coordinatorPubkey: coordinator.serverPubkey,
relays: coordinator.relays,
});
});
return code ?? null;
}, [groupId, coordinator, run]);
const addDevice = useCallback(
async (raw: string) => {
const session = sessionRef.current;
const link = decodeDeviceLink(raw);
if (!session || !group || !coordinator || !link) return false;
if (link.gid !== groupId || link.coordinatorPubkey !== coordinator.serverPubkey) return false;
const added = await run(async () => {
try {
await session.addMemberByIdentifier(group.gid, link.kpRef);
} catch {
await session.addMemberByIdentifier(group.gid, link.pubkey);
}
const next = session.listGroups().find((g) => g.gid === group.gid) ?? group;
setGroup(next);
setMessages(session.messages(group.gid));
return true;
});
return added === true;
},
[group, groupId, coordinator, run],
);
const approve = useCallback(
async (identifier: string) => {
const session = sessionRef.current;
if (!session || !group) return false;
const approved = await run(async () => {
await session.addMemberByIdentifier(group.gid, identifier);
const pending = await session.fetchJoinRequests(group.gid);
setRequests(pending);
return true;
});
return approved === true;
},
[group, run],
);
return {
ready,
coordinator,
groupId,
group,
messages,
busy,
error,
send,
refresh,
createChat,
requestJoin,
checkInvite,
requests,
loadRequests,
loadRequestsQuiet,
approve,
removeMember,
createDeviceLink,
addDevice,
interactiveHintVisible: interactive,
passiveSigner,
groupRef,
keyPackageReady,
retry,
};
}
/**
* Purely local check: does this account already have a saved MLS group for
* `gid`? No coordinator request and no signer prompt — it reads the IndexedDB
* snapshot. Re-runs when `refreshKey` changes. `undefined` until known.
*/
export function useCordnGroupExists(
accountPubkey: string | undefined,
gid: string,
refreshKey?: unknown,
): boolean | undefined {
const [exists, setExists] = useState<boolean | undefined>(undefined);
useEffect(() => {
let cancelled = false;
if (!accountPubkey || !gid) {
setExists(undefined);
return;
}
cordnGroupExists(accountPubkey, gid)
.then((value) => {
if (!cancelled) setExists(value);
})
.catch(() => {
if (!cancelled) setExists(false);
});
return () => {
cancelled = true;
};
}, [accountPubkey, gid, refreshKey]);
return exists;
}
/**
* All MLS group ids this account holds (one IndexedDB read). Lets a list page
* mark which crews already have a private chat without a per-crew read.
*/
export function useCordnGroupGids(
accountPubkey: string | undefined,
refreshKey?: unknown,
): Set<string> | undefined {
const [gids, setGids] = useState<Set<string> | undefined>(undefined);
useEffect(() => {
let cancelled = false;
if (!accountPubkey) {
setGids(undefined);
return;
}
cordnGroupGids(accountPubkey)
.then((value) => {
if (!cancelled) setGids(value);
})
.catch(() => {
if (!cancelled) setGids(new Set());
});
return () => {
cancelled = true;
};
}, [accountPubkey, refreshKey]);
return gids;
}