- DirectMessagesPage gains a 'Crews' block listing the viewer's crews with an Open/Set-up badge (from one local IndexedDB read via useCordnGroupGids), linking into /crew/<pk>/<id>?tab=mls. - Crew page tabs are now URL-addressable (?tab=events|chat|mls); an explicit param wins over the auto-default. useUserCrews + cordnGroupGids added.
358 lines
11 KiB
TypeScript
358 lines
11 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 } 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|