import type { AbstractRelay } from "@nostr/tools/abstract-relay"; import type { Event } from "@nostr/tools/core"; import { auth } from "$lib/auth.svelte"; import { GROUP_ID, MODE } from "$lib/config"; import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay"; import { getGroupName } from "$lib/group.svelte"; import { showToast } from "$lib/toast.svelte"; type Membership = "member" | "guest"; // "none": the relay doesn't support NIP-43, so no relay-level join exists. type RelayMembership = Membership | "none"; // Writing to a group can be gated at two levels: the relay itself (NIP-43) and // the group (NIP-29). Neither gate is advertised up front, so membership is // probed by reading the relay's membership events and, failing that, by trying // to join and reacting to the rejection. Both caches are keyed by pubkey so a // silent session restore (anon → pubkey) re-evaluates instead of reusing the // "guest" cached before auth.user was populated. let membership = $state>({}); let relayMembership = $state>({}); const checking = new Map>(); // The modal only appears when a relay refused a step and needs user input. // - relay-code: the relay requires an invite code (NIP-43 claim) // - group-code: the group refused the join; a code may be needed // - pending: the group accepted the request but membership isn't granted yet // (admin approval); an invite code, if any, can still be tried // - error: a terminal refusal (e.g. banned); nothing the user can enter helps export type JoinStep = "relay-code" | "group-code" | "pending" | "error"; let modalOpen = $state(false); let modalStep = $state("group-code"); let modalGroup = $state(null); let modalError = $state(null); // A rejection message that mentions a code makes the code field the primary // affordance rather than a discreet fallback. let modalCodeHinted = $state(false); let busy = $state(false); // Continuation to run once access is granted (open the composer, focus the // reply box, reload a now-visible feed, or retry the original post). let onJoined: (() => void | Promise) | null = null; const memberKey = (groupId: string) => `${auth.user?.pubkey ?? "anon"}:${groupId}`; export const joinState = { get modalOpen() { return modalOpen; }, get modalStep() { return modalStep; }, get modalError() { return modalError; }, get modalCodeHinted() { return modalCodeHinted; }, get busy() { return busy; }, }; export function resetJoinState() { membership = {}; relayMembership = {}; modalOpen = false; modalGroup = null; modalError = null; modalCodeHinted = false; busy = false; onJoined = null; } export function membershipOf(groupId: string): Membership | "unknown" { return membership[memberKey(groupId)] ?? "unknown"; } // Resolves membership for a group once and caches it. Logged-out users are // always guests; the relay query only runs when signed in. Returns the shared // in-flight promise when a check is already running, so an awaiting caller // (e.g. clicking "New discussion") gets the real result rather than racing past // an unresolved "unknown". export function ensureMembershipChecked(groupId: string): Promise { if (!groupId) return Promise.resolve(); const pubkey = auth.user?.pubkey; const k = memberKey(groupId); if (membership[k]) return Promise.resolve(); if (!pubkey) { membership[k] = "guest"; return Promise.resolve(); } const existing = checking.get(k); if (existing) return existing; const p = (async () => { try { const isMember = await checkMembership(pubkey, groupId); membership[k] = isMember ? "member" : "guest"; } finally { checking.delete(k); } })(); checking.set(k, p); return p; } type SubFilter = Parameters[0][number]; // Collects every event matching the filter until EOSE/close/timeout. function queryEvents( relay: AbstractRelay, filter: SubFilter, timeoutMs = 3000, ): Promise { return new Promise((resolve) => { let settled = false; const events: Event[] = []; const finish = () => { if (settled) return; settled = true; try { sub.close(); } catch {} resolve(events); }; const sub = relay.subscribe([filter], { onevent(e) { events.push(e); }, oneose: finish, onclose: finish, }); setTimeout(finish, timeoutMs); }); } // Existence check for a single matching event (resolves true on first event, // false on EOSE/close/timeout). function queryHasMatch( relay: AbstractRelay, filter: SubFilter, timeoutMs = 3000, ): Promise { return new Promise((resolve) => { let settled = false; const finish = (val: boolean) => { if (settled) return; settled = true; try { sub.close(); } catch {} resolve(val); }; const sub = relay.subscribe([filter], { onevent() { finish(true); }, oneose() { finish(false); }, onclose() { finish(false); }, }); setTimeout(() => finish(false), timeoutMs); }); } // Newest `created_at` among events matching the filter, or null if none. We // don't trust the relay to honour limit ordering, so we keep the max across all // returned events (the #p filter keeps the set tiny). async function latestCreatedAt( relay: AbstractRelay, filter: SubFilter, ): Promise { const events = await queryEvents(relay, filter); return events.reduce( (max, e) => (max === null || e.created_at > max ? e.created_at : max), null, ); } // Add/remove events are the freshest signal: the newest of the two decides // (a later removal unjoins). When the relay keeps neither, the list decides. function decideMembership( added: number | null, removed: number | null, listed: boolean, ): boolean { if (added !== null || removed !== null) { return added !== null && added >= (removed ?? -Infinity); } return listed; } // Group membership — i.e. "is this user allowed to write here". Admins // (kind:39001) can post but need not appear in the members list, so they count // as members. Otherwise NIP-29 records membership as kind:9000 (add) and // kind:9001 (remove) moderation events, with the kind:39002 list as fallback. async function checkMembership( pubkey: string, groupId: string, ): Promise { let relay: AbstractRelay; try { relay = await ensureForumRelay(); } catch { return false; } const [isAdmin, added, removed, listed] = await Promise.all([ queryHasMatch(relay, { kinds: [39001], "#d": [groupId], "#p": [pubkey], limit: 1, }), latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }), latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }), queryHasMatch(relay, { kinds: [39002], "#d": [groupId], "#p": [pubkey], limit: 1, }), ]); if (isAdmin) return true; return decideMembership(added, removed, listed); } // Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and // kind:8001 (remove) signed by the relay, with the kind:13534 list as fallback. // The `member` tag isn't filterable, so the list is fetched and scanned. async function checkRelayMembership( pubkey: string, self: string, ): Promise { let relay: AbstractRelay; try { relay = await ensureForumRelay(); } catch { return false; } const [added, removed, lists] = await Promise.all([ latestCreatedAt(relay, { kinds: [8000], authors: [self], "#p": [pubkey] }), latestCreatedAt(relay, { kinds: [8001], authors: [self], "#p": [pubkey] }), queryEvents(relay, { kinds: [13534], authors: [self], limit: 1 }), ]); const listed = lists.some((e) => e.tags.some((t) => t[0] === "member" && t[1] === pubkey), ); return decideMembership(added, removed, listed); } function ensureRelayMembershipChecked(pubkey: string): Promise { if (relayMembership[pubkey]) return Promise.resolve(); const k = `relay:${pubkey}`; const existing = checking.get(k); if (existing) return existing; const p = (async () => { try { const self = await nip43Self(); if (!self) { relayMembership[pubkey] = "none"; return; } const isMember = await checkRelayMembership(pubkey, self); relayMembership[pubkey] = isMember ? "member" : "guest"; } finally { checking.delete(k); } })(); checking.set(k, p); return p; } // Probes both membership levels at login so a returning member's first post // needs no join at all. Full mode checks groups lazily per room instead. export async function initJoinForUser(pubkey: string) { await Promise.all([ ensureRelayMembershipChecked(pubkey), MODE === "simple" ? ensureMembershipChecked(GROUP_ID) : undefined, ]); } // Publishing rejections carry the relay's NIP-01 OK message; the prefix is the // only cross-relay contract for what went wrong. function reason(e: unknown): string { return e instanceof Error ? e.message : String(e); } const prefixed = (msg: string, prefix: string) => msg.toLowerCase().startsWith(prefix); const stripPrefix = (msg: string) => msg.replace(/^[a-z-]+:\s*/i, ""); const mentionsCode = (msg: string) => /\b(code|invite|claim)\b/i.test(msg); async function publishSigned(kind: number, tags: string[][]): Promise { if (!auth.signer) throw new Error("Not logged in"); const event = await auth.signer.signEvent({ kind, created_at: Math.floor(Date.now() / 1000), tags, content: "", }); const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Relay did not respond in time")), 8000), ); await Promise.race([Promise.all(publishForum(event)), timeout]); } type Codes = { relay?: string; group?: string }; type Refusal = { step: JoinStep; message: string | null; codeHinted: boolean }; type Outcome = { ok: true } | ({ ok: false } & Refusal); const fail = ( step: JoinStep, message: string | null, codeHinted = false, ): Outcome => ({ ok: false, step, message, codeHinted }); // Bare relay joins the relay declined, per pubkey. A refusal is only a hint: // some relays (pyramid) let non-members into open groups, so the group join // decides whether relay membership was needed. Remembering it spares one // doomed signature per room. const relayRefusals: Record = {}; // Relay-level join (NIP-43). Returns the refusal, if any, without acting on it. async function tryRelayJoin(pubkey: string, codes: Codes): Promise { await ensureRelayMembershipChecked(pubkey); if (relayMembership[pubkey] !== "guest") return { ok: true }; if (!codes.relay && relayRefusals[pubkey]) return relayRefusals[pubkey]; const tags: string[][] = [["-"]]; if (codes.relay) tags.push(["claim", codes.relay]); let outcome: Outcome = { ok: true }; let policy = false; try { await publishSigned(28934, tags); relayMembership[pubkey] = "member"; } catch (e) { const msg = reason(e); policy = prefixed(msg, "restricted:") || prefixed(msg, "blocked:"); if (prefixed(msg, "duplicate:")) { relayMembership[pubkey] = "member"; } else if (prefixed(msg, "blocked:")) { outcome = fail("error", stripPrefix(msg)); } else { // A bare request refused is the expected probe, not a user error outcome = fail("relay-code", codes.relay ? stripPrefix(msg) : null, true); } } if (outcome.ok) delete relayRefusals[pubkey]; else if (!codes.relay && policy) relayRefusals[pubkey] = outcome; return outcome; } // Runs the two-level join: relay first (a group join may need relay // membership), then group. Everything free happens silently; the first // refusal that actually blocks is reported as the step needing user input. async function ensureAccess(groupId: string, codes: Codes): Promise { const pubkey = auth.user?.pubkey; if (!pubkey) return fail("error", "Not logged in"); await ensureMembershipChecked(groupId); const k = memberKey(groupId); if (membership[k] === "member") return { ok: true }; const relay = await tryRelayJoin(pubkey, codes); // A code the user typed is judged at once; a bare refusal only counts if // the group join fails too if (!relay.ok && codes.relay) return relay; const tags: string[][] = [["h", groupId]]; if (codes.group) tags.push(["code", codes.group]); try { await publishSigned(9021, tags); } catch (e) { const msg = reason(e); if (!prefixed(msg, "duplicate:")) { if (prefixed(msg, "restricted:") && !relay.ok) return relay; if (prefixed(msg, "restricted:")) { const hinted = mentionsCode(msg); return fail( "group-code", codes.group || !hinted ? stripPrefix(msg) : null, hinted, ); } return fail("error", stripPrefix(msg)); } } // An accepted request doesn't imply membership: closed groups may hold it // for admin approval, so re-read the relay's records before trusting it. if (await checkMembership(pubkey, groupId)) { membership[k] = "member"; showToast(`You joined ${getGroupName(groupId)}`); return { ok: true }; } return fail("pending", codes.group ? "The code was not accepted" : null); } function openModal( groupId: string | null, refusal: Refusal, cb: (() => void | Promise) | null, ) { modalGroup = groupId; onJoined = cb; modalStep = refusal.step; modalCodeHinted = refusal.codeHinted; modalError = refusal.message; modalOpen = true; } // Wraps an action that posts to `groupId`, joining relay and group first when // needed. A write refused for membership reasons (e.g. a stale cache after the // relay changed policy) invalidates both caches and retries once from the top. // Returns false when the modal took over; the action then runs on its success. export async function withJoin( groupId: string, action: () => void | Promise, ): Promise { if (busy) return false; busy = true; try { return await runWithAccess(groupId, action, {}, true); } finally { busy = false; } } async function runWithAccess( groupId: string, action: () => void | Promise, codes: Codes, retry: boolean, ): Promise { const outcome = await ensureAccess(groupId, codes); if (!outcome.ok) { openModal(groupId, outcome, action); return false; } try { await action(); return true; } catch (e) { const pubkey = auth.user?.pubkey; if (retry && pubkey && prefixed(reason(e), "restricted:")) { delete membership[memberKey(groupId)]; delete relayRefusals[pubkey]; if (relayMembership[pubkey] === "member") relayMembership[pubkey] = "guest"; return runWithAccess(groupId, action, codes, false); } throw e; } } export function closeJoinModal() { if (busy) return; modalOpen = false; modalGroup = null; modalError = null; onJoined = null; } // Re-runs the join with the code entered for the current step. Errors keep the // modal open for a retry; a further gate switches the modal to that step. export async function submitJoinCode(code?: string) { if (!modalOpen || busy) return; busy = true; modalError = null; const groupId = modalGroup; const cb = onJoined; const codes: Codes = modalStep === "relay-code" ? { relay: code } : { group: code }; try { const pubkey = auth.user?.pubkey; const outcome = groupId ? await ensureAccess(groupId, codes) : pubkey ? await tryRelayJoin(pubkey, codes) : fail("error", "Not logged in"); if (!outcome.ok) { openModal(groupId, outcome, cb); return; } modalOpen = false; modalGroup = null; onJoined = null; if (cb) await cb(); } catch (e) { modalError = reason(e); } finally { busy = false; } } // Relay-only join, for a relay that refuses reads from non-members: no group // is involved, so the modal (if any) targets the relay alone. export async function joinRelay(cb: () => void | Promise): Promise { const pubkey = auth.user?.pubkey; if (!pubkey || busy) return; busy = true; try { const outcome = await tryRelayJoin(pubkey, {}); if (!outcome.ok) { openModal(null, outcome, cb); return; } await cb(); } finally { busy = false; } }