From d965cdef01d1ecf45485f7f6e002247b2667407a Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 9 Jun 2026 13:00:46 +0100 Subject: [PATCH] Authenticate over NIP-42 so members see hidden rooms + use one shared SimplePool --- src/lib/admins.svelte.ts | 8 +-- src/lib/auth.svelte.ts | 10 ++++ src/lib/chat.svelte.ts | 36 +++--------- src/lib/draft.svelte.ts | 25 +++------ src/lib/group.svelte.ts | 8 +-- src/lib/groups.svelte.ts | 7 +-- src/lib/join.svelte.ts | 64 ++++++++------------- src/lib/moderation.svelte.ts | 22 ++------ src/lib/overview.svelte.ts | 10 ++-- src/lib/partials.svelte.ts | 7 +-- src/lib/profiles.svelte.ts | 10 ++-- src/lib/relay.ts | 106 +++++++++++++++++++++++++++++++++++ src/lib/resources.svelte.ts | 7 +-- src/lib/thread.svelte.ts | 76 ++++++++++--------------- src/lib/threadRefs.ts | 16 +----- src/lib/threads.svelte.ts | 18 +++--- src/routes/+layout.svelte | 25 +++++++++ 17 files changed, 251 insertions(+), 204 deletions(-) create mode 100644 src/lib/relay.ts diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts index bbe78e4..7345f65 100644 --- a/src/lib/admins.svelte.ts +++ b/src/lib/admins.svelte.ts @@ -1,6 +1,6 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL, MODE } from "$lib/config"; +import { MODE } from "$lib/config"; import { groupStore } from "$lib/group.svelte"; +import { queryForum } from "$lib/relay"; // room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms. let byRoom = $state>({}); @@ -47,9 +47,8 @@ export async function loadRoomAdmins(roomIds: string[]) { if (key === loadedKey) return; loadedKey = key; - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { + const events = await queryForum({ kinds: [39001], "#d": roomIds, }); @@ -62,6 +61,5 @@ export async function loadRoomAdmins(roomIds: string[]) { byRoom = map; } finally { loaded = true; - pool.close([RELAY_URL]); } } diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index 6c604e9..89c192c 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -19,6 +19,10 @@ export type Signer = { let user = $state(null); let signer = $state(null); let loginModalOpen = $state(false); +// Bumped on explicit login/logout (not on silent session restore) so the app +// can re-fetch identity-scoped data — e.g. reload the room list once the relay +// will serve the user's private/hidden groups. +let sessionEpoch = $state(0); export const auth = { get user() { @@ -30,6 +34,9 @@ export const auth = { get loginModalOpen() { return loginModalOpen; }, + get sessionEpoch() { + return sessionEpoch; + }, }; const PUBKEY_KEY = "nostr_pubkey"; @@ -89,6 +96,7 @@ export async function loginWithExtension() { localStorage.setItem(METHOD_KEY, "extension"); localStorage.removeItem(NSEC_KEY); await setUser(pubkey); + sessionEpoch++; } function parseSecretKey(input: string): Uint8Array { @@ -119,6 +127,7 @@ export async function loginWithNsec(input: string) { localStorage.setItem(METHOD_KEY, "nsec"); localStorage.setItem(NSEC_KEY, nsec); await setUser(pubkey); + sessionEpoch++; } export function logout() { @@ -128,6 +137,7 @@ export function logout() { localStorage.removeItem(METHOD_KEY); localStorage.removeItem(NSEC_KEY); resetJoinState(); + sessionEpoch++; } export async function restoreSession() { diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 6cab3ca..1517961 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -1,7 +1,8 @@ -import { SimplePool, type Event } from "@nostr/tools"; +import type { Event } from "@nostr/tools"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; import { RELAY_URL } from "$lib/config"; import { auth } from "$lib/auth.svelte"; +import { queryForum, publishForum, subscribeForum } from "$lib/relay"; import { ingestNostrUser } from "$lib/profiles.svelte"; import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; import { convertForumUrls } from "$lib/linkify"; @@ -19,7 +20,6 @@ let messages = $state([]); let profiles = $state>({}); let currentGroup: string | null = null; let chatReq = 0; // supersedes an in-flight load when the room changes -let livePool: SimplePool | null = null; let liveSub: { close(): void } | null = null; export const chatStore = { @@ -80,14 +80,11 @@ export async function startChat(groupId: string) { const req = ++chatReq; liveSub?.close(); - livePool?.close([RELAY_URL]); liveSub = null; - livePool = null; messages = []; - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { + const events = await queryForum({ kinds: [9], "#h": [groupId], limit: 100, @@ -96,15 +93,11 @@ export async function startChat(groupId: string) { for (const ev of events) ingestEvent(ev); } catch (e) { console.error("[chat] initial load failed", e); - } finally { - pool.close([RELAY_URL]); } if (req !== chatReq) return; - livePool = new SimplePool(); - liveSub = livePool.subscribeMany( - [RELAY_URL], + liveSub = subscribeForum( { kinds: [9], "#h": [groupId], @@ -117,9 +110,7 @@ export async function startChat(groupId: string) { export function stopChat() { chatReq++; liveSub?.close(); - livePool?.close([RELAY_URL]); liveSub = null; - livePool = null; currentGroup = null; messages = []; } @@ -164,21 +155,10 @@ export async function sendChatMessage( content, }); - const pool = new SimplePool(); - try { - const timeout = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Relay did not respond in time")), - 8000, - ), - ); - await Promise.race([ - Promise.all(pool.publish([RELAY_URL], signed)), - timeout, - ]); - } finally { - pool.destroy(); - } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Relay did not respond in time")), 8000), + ); + await Promise.race([Promise.all(publishForum(signed)), timeout]); ingestEvent(signed); } diff --git a/src/lib/draft.svelte.ts b/src/lib/draft.svelte.ts index 7ffba67..7f53965 100644 --- a/src/lib/draft.svelte.ts +++ b/src/lib/draft.svelte.ts @@ -1,8 +1,7 @@ -import { SimplePool } from "@nostr/tools"; import { auth } from "$lib/auth.svelte"; import { withJoin } from "$lib/join.svelte"; import { activeGroup } from "$lib/active.svelte"; -import { RELAY_URL } from "$lib/config"; +import { publishForum } from "$lib/relay"; import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; import { convertForumUrls } from "$lib/linkify"; @@ -139,21 +138,13 @@ export async function publishDraft(): Promise<{ content: c, }); - const pool = new SimplePool(); - try { - const timeout = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Relay did not respond in time")), - 8000, - ), - ); - await Promise.race([ - Promise.all(pool.publish([RELAY_URL], event)), - timeout, - ]); - } finally { - pool.destroy(); - } + const timeout = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Relay did not respond in time")), + 8000, + ), + ); + await Promise.race([Promise.all(publishForum(event)), timeout]); threadId = event.id; }); diff --git a/src/lib/group.svelte.ts b/src/lib/group.svelte.ts index ce70c66..bd5afc1 100644 --- a/src/lib/group.svelte.ts +++ b/src/lib/group.svelte.ts @@ -1,5 +1,5 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL, GROUP_ID } from "$lib/config"; +import { GROUP_ID } from "$lib/config"; +import { queryForum } from "$lib/relay"; export type GroupMetadata = { name: string; @@ -23,9 +23,8 @@ export const groupStore = { }; export async function loadGroup() { - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { + const events = await queryForum({ kinds: [39000, 39001], "#d": [GROUP_ID], }); @@ -45,6 +44,5 @@ export async function loadGroup() { }; } finally { loaded = true; - pool.close([RELAY_URL]); } } diff --git a/src/lib/groups.svelte.ts b/src/lib/groups.svelte.ts index 7b91e54..ce0ceab 100644 --- a/src/lib/groups.svelte.ts +++ b/src/lib/groups.svelte.ts @@ -1,5 +1,4 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL } from "$lib/config"; +import { queryForum } from "$lib/relay"; export type GroupSummary = { id: string; // NIP-29 group id (the `d` tag) — also the room URL slug @@ -24,9 +23,8 @@ export const groupsStore = { // Fetch every group the relay hosts. NIP-29 publishes one kind 39000 metadata // event per group, so an unfiltered query enumerates them all. export async function loadGroups() { - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { kinds: [39000] }); + const events = await queryForum({ kinds: [39000] }); list = events .map((e) => { const id = e.tags.find((t) => t[0] === "d")?.[1] ?? ""; @@ -43,6 +41,5 @@ export async function loadGroups() { .sort((a, b) => a.name.localeCompare(b.name)); } finally { loaded = true; - pool.close([RELAY_URL]); } } diff --git a/src/lib/join.svelte.ts b/src/lib/join.svelte.ts index 42b3b59..b8abfa9 100644 --- a/src/lib/join.svelte.ts +++ b/src/lib/join.svelte.ts @@ -1,6 +1,7 @@ -import { Relay, SimplePool } from "@nostr/tools"; +import type { AbstractRelay } from "@nostr/tools/abstract-relay"; import { auth } from "$lib/auth.svelte"; -import { GROUP_ID, MODE, RELAY_URL, JOINCODE_REQUIRED } from "$lib/config"; +import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config"; +import { ensureForumRelay, publishForum } from "$lib/relay"; // Membership is per-group: full mode lets a user belong to some rooms but not // others, so we track joined group ids rather than a single boolean. @@ -39,8 +40,8 @@ export function resetJoinState() { // first, falling back to kind:39002 (full members list, heavier) only if 9000 // did not match. Either signal marks the user as joined and skips the 9021. function queryHasMatch( - relay: Relay, - filter: Parameters[0][number], + relay: AbstractRelay, + filter: Parameters[0][number], timeoutMs = 3000, ): Promise { return new Promise((resolve) => { @@ -74,31 +75,25 @@ async function checkMembership( pubkey: string, groupId: string, ): Promise { - let relay: Relay; + let relay: AbstractRelay; try { - relay = await Relay.connect(RELAY_URL); + relay = await ensureForumRelay(); } catch { return false; } - try { - const has9000 = await queryHasMatch(relay, { - kinds: [9000], - "#h": [groupId], - "#p": [pubkey], - limit: 1, - }); - if (has9000) return true; - return await queryHasMatch(relay, { - kinds: [39002], - "#d": [groupId], - "#p": [pubkey], - limit: 1, - }); - } finally { - try { - relay.close(); - } catch {} - } + const has9000 = await queryHasMatch(relay, { + kinds: [9000], + "#h": [groupId], + "#p": [pubkey], + limit: 1, + }); + if (has9000) return true; + return await queryHasMatch(relay, { + kinds: [39002], + "#d": [groupId], + "#p": [pubkey], + limit: 1, + }); } // Simple mode pre-checks the single configured group at login so the first post @@ -125,21 +120,10 @@ async function publishJoinRequest(groupId: string, code?: string) { tags, content: "", }); - const pool = new SimplePool(); - try { - const timeout = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Relay did not respond in time")), - 8000, - ), - ); - await Promise.race([ - Promise.all(pool.publish([RELAY_URL], event)), - timeout, - ]); - } finally { - pool.destroy(); - } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Relay did not respond in time")), 8000), + ); + await Promise.race([Promise.all(publishForum(event)), timeout]); } // Wraps an action that posts to `groupId`. If the user isn't known to be a diff --git a/src/lib/moderation.svelte.ts b/src/lib/moderation.svelte.ts index 5465331..607a1cd 100644 --- a/src/lib/moderation.svelte.ts +++ b/src/lib/moderation.svelte.ts @@ -1,6 +1,5 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL } from "$lib/config"; import { auth } from "$lib/auth.svelte"; +import { publishForum } from "$lib/relay"; // What's pending deletion, surfaced to the confirmation modal. `label` is the // noun shown in the dialog copy ("discussion", "reply", "message"). @@ -70,21 +69,10 @@ export async function confirmDelete(reason?: string) { content: reason?.trim() ?? "", }); - const pool = new SimplePool(); - try { - const timeout = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Relay did not respond in time")), - 8000, - ), - ); - await Promise.race([ - Promise.all(pool.publish([RELAY_URL], signed)), - timeout, - ]); - } finally { - pool.destroy(); - } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Relay did not respond in time")), 8000), + ); + await Promise.race([Promise.all(publishForum(signed)), timeout]); onDeleted?.(); target = null; diff --git a/src/lib/overview.svelte.ts b/src/lib/overview.svelte.ts index 9322b5e..6b19b76 100644 --- a/src/lib/overview.svelte.ts +++ b/src/lib/overview.svelte.ts @@ -1,8 +1,8 @@ -import { Relay } from "@nostr/tools"; +import type { AbstractRelay } from "@nostr/tools/abstract-relay"; import type { Event } from "@nostr/tools/core"; import type { Filter } from "@nostr/tools/filter"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; -import { RELAY_URL } from "$lib/config"; +import { ensureForumRelay } from "$lib/relay"; import { ingestNostrUser } from "$lib/profiles.svelte"; export type RoomActivity = { @@ -43,7 +43,7 @@ export const overviewStore = { }, }; -function querySync(relay: Relay, filter: Filter): Promise { +function querySync(relay: AbstractRelay, filter: Filter): Promise { return new Promise((resolve) => { const events: Event[] = []; const sub = relay.subscribe([filter], { @@ -77,7 +77,7 @@ export async function loadOverview(roomIds: string[]) { loadedKey = key; loading = true; - const relay = await Relay.connect(RELAY_URL); + const relay = await ensureForumRelay(); try { // One tiny query per room for its newest event (thread or reply). const latest = await Promise.all( @@ -122,7 +122,7 @@ export async function loadOverview(roomIds: string[]) { for (const pk of Object.values(adm)) loadProfile(pk); for (const t of recent) loadProfile(t.authorPubkey); } finally { - relay.close(); + // shared forum connection is long-lived — don't close it here loading = false; } } diff --git a/src/lib/partials.svelte.ts b/src/lib/partials.svelte.ts index e2fe396..ba45c49 100644 --- a/src/lib/partials.svelte.ts +++ b/src/lib/partials.svelte.ts @@ -1,6 +1,5 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL } from "$lib/config"; import { adminPubkeys } from "$lib/admins.svelte"; +import { queryForum } from "$lib/relay"; // Named slots a partial can fill. The NIP-23 `d` tag carries the slot name. export type PartialSlot = "home" | "contacts"; @@ -40,9 +39,8 @@ export const partialsStore = { // Partials are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-partial"]; // the `d` tag names the slot the article fills. export async function loadPartials() { - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { + const events = await queryForum({ kinds: [30023], "#t": ["squalk-partial"], }); @@ -62,6 +60,5 @@ export async function loadPartials() { all = next; } finally { loaded = true; - pool.close([RELAY_URL]); } } diff --git a/src/lib/profiles.svelte.ts b/src/lib/profiles.svelte.ts index ed879a3..c56428a 100644 --- a/src/lib/profiles.svelte.ts +++ b/src/lib/profiles.svelte.ts @@ -2,7 +2,8 @@ import { SimplePool, type Event } from "@nostr/tools"; import * as nip19 from "@nostr/tools/nip19"; import { SvelteMap, SvelteSet } from "svelte/reactivity"; import type { NostrUser } from "@nostr/gadgets/metadata"; -import { RELAY_URL, GROUP_ID } from "$lib/config"; +import { GROUP_ID } from "$lib/config"; +import { queryForum } from "$lib/relay"; export type ProfileEntry = { pubkey: string; @@ -191,8 +192,9 @@ export function seedProfiles(userPubkey: string | null): Promise { async function doSeedProfiles(userPubkey: string | null) { const pool = new SimplePool(); try { - // Group members live on the forum relay (NIP-29). - const groupEvents = await pool.querySync([RELAY_URL], { + // Group members live on the forum relay (NIP-29); query it through the + // shared authenticated connection so private-group members are included. + const groupEvents = await queryForum({ kinds: [39002], "#d": [GROUP_ID], limit: 1, @@ -267,7 +269,7 @@ async function doSeedProfiles(userPubkey: string | null) { localStorage.setItem(LAST_SYNC_KEY, String(Math.floor(Date.now() / 1000))); } finally { - pool.close([RELAY_URL, ...PROFILE_RELAYS]); + pool.close(PROFILE_RELAYS); } } diff --git a/src/lib/relay.ts b/src/lib/relay.ts new file mode 100644 index 0000000..e850417 --- /dev/null +++ b/src/lib/relay.ts @@ -0,0 +1,106 @@ +import { SimplePool } from "@nostr/tools"; +import type { AbstractRelay } from "@nostr/tools/abstract-relay"; +import type { Event, EventTemplate } from "@nostr/tools/core"; +import type { Filter } from "@nostr/tools/filter"; +import { RELAY_URL } from "$lib/config"; +import { auth } from "$lib/auth.svelte"; + +// Single long-lived pool for the forum relay. NIP-42 auth is wired here so a +// logged-in user's private and hidden rooms are served, and the connection +// persists so the handshake happens once rather than per request. Auth is +// scoped to the forum relay — external relays (profiles, search) are never +// authenticated against. +const pool = new SimplePool(); + +// Scoped to the forum relay — external relays (profiles, search) are never +// authenticated against. Re-evaluated on every connection, so it picks up a +// login without a restart. +pool.automaticallyAuth = (url: string) => { + if (url !== RELAY_URL) return null; + const signer = auth.signer; + if (!signer) return null; + return (evt: EventTemplate) => signer.signEvent(evt); +}; + +export const forumPool = pool; + +function authParam() { + const signer = auth.signer; + return signer ? { onauth: (evt: EventTemplate) => signer.signEvent(evt) } : {}; +} + +// Resolves once the forum connection is open and, when logged in, NIP-42 +// authenticated. A broad metadata listing never triggers `auth-required`, so the +// handshake must finish *before* querying or hidden groups are silently filtered +// out of the response. `auth()` throws until the relay's on-connect challenge +// lands and resolves on its OK; it is idempotent, so this coexists with the +// pool's automatic auth. Bounded so a missing/declined signer can't block reads. +export async function ensureForumReady(): Promise { + const signer = auth.signer; + if (!signer) return; // anonymous: only public groups are visible anyway + let relay: AbstractRelay; + try { + relay = await pool.ensureRelay(RELAY_URL); + } catch { + return; // connection failed; the caller's own query surfaces the error + } + const sign = (evt: EventTemplate) => signer.signEvent(evt); + const deadline = Date.now() + 3500; + while (Date.now() < deadline) { + try { + await Promise.race([ + relay.auth(sign), + new Promise((_, reject) => + setTimeout(() => reject(new Error("auth-timeout")), 3000), + ), + ]); + return; // authenticated + } catch (e) { + // The challenge may not have arrived yet — wait briefly and retry. + if (e instanceof Error && e.message.includes("no challenge")) { + await new Promise((r) => setTimeout(r, 25)); + continue; + } + return; // auth failed or timed out: fall back to public visibility + } + } +} + +// Query the forum relay, authenticating first so private/hidden content is +// served to members; anonymous users transparently get the public subset. +export async function queryForum( + filter: Filter, + params?: { maxWait?: number; label?: string }, +): Promise { + await ensureForumReady(); + return pool.querySync([RELAY_URL], filter, params); +} + +// Publish to the forum relay. `onauth` covers the reactive case where the relay +// rejects an unauthenticated write to a private/closed group. +export function publishForum(event: Event): Promise[] { + return pool.publish([RELAY_URL], event, authParam()); +} + +// Live subscription to the forum relay (e.g. chat). `onauth` lets the library +// authenticate and resubscribe if the relay rejects the subscription. +export function subscribeForum( + filter: Filter, + params: Parameters[2], +): { close(): void } { + return pool.subscribeMany([RELAY_URL], filter, { ...authParam(), ...params }); +} + +// Shared connected (and, when logged in, authenticated) relay for code paths +// that drive their own subscriptions: paged thread loads, the overview, the +// membership probe. Never close it — the pool owns its lifecycle. +export async function ensureForumRelay(): Promise { + await ensureForumReady(); + return pool.ensureRelay(RELAY_URL); +} + +// Drop the forum connection so the next use reconnects and re-runs the AUTH +// handshake with the current signer. Called on the logout transition. +export function resetForumConnection(): void { + pool.close([RELAY_URL]); +} diff --git a/src/lib/resources.svelte.ts b/src/lib/resources.svelte.ts index 4740f63..7d4b321 100644 --- a/src/lib/resources.svelte.ts +++ b/src/lib/resources.svelte.ts @@ -1,6 +1,5 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL } from "$lib/config"; import { adminPubkeys } from "$lib/admins.svelte"; +import { queryForum } from "$lib/relay"; export type Resource = { id: string; @@ -41,9 +40,8 @@ function compare(a: Resource, b: Resource): number { // Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"]. export async function loadResources() { - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { + const events = await queryForum({ kinds: [30023], "#t": ["squalk-resource"], }); @@ -69,6 +67,5 @@ export async function loadResources() { all = [...bySlug.values()].sort(compare); } finally { loaded = true; - pool.close([RELAY_URL]); } } diff --git a/src/lib/thread.svelte.ts b/src/lib/thread.svelte.ts index c871acf..ee9c730 100644 --- a/src/lib/thread.svelte.ts +++ b/src/lib/thread.svelte.ts @@ -1,6 +1,6 @@ -import { SimplePool } from "@nostr/tools"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; import { RELAY_URL, GROUP_ID } from "$lib/config"; +import { queryForum, publishForum } from "$lib/relay"; import { threads as mockThreads } from "$lib/mock"; import { auth } from "$lib/auth.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte"; @@ -96,41 +96,36 @@ export async function loadThread(id: string) { return; } - const pool = new SimplePool(); - try { - const [threadEvents, replyEvents] = await Promise.all([ - pool.querySync([RELAY_URL], { kinds: [11], ids: [id] }), - pool.querySync([RELAY_URL], { kinds: [1111], "#E": [id] }), - ]); + const [threadEvents, replyEvents] = await Promise.all([ + queryForum({ kinds: [11], ids: [id] }), + queryForum({ kinds: [1111], "#E": [id] }), + ]); - const event = threadEvents[0]; - if (!event) return; + const event = threadEvents[0]; + if (!event) return; - const replies = replyEvents.sort((a, b) => a.created_at - b.created_at); + const replies = replyEvents.sort((a, b) => a.created_at - b.created_at); - detail = { + detail = { + id: event.id, + title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", + labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]), + groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID, + op: { id: event.id, - title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", - labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]), - groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID, - op: { - id: event.id, - pubkey: event.pubkey, - createdAt: event.created_at, - content: event.content, - }, - replies: replies.map((r) => ({ - id: r.id, - pubkey: r.pubkey, - createdAt: r.created_at, - content: r.content, - })), - }; + pubkey: event.pubkey, + createdAt: event.created_at, + content: event.content, + }, + replies: replies.map((r) => ({ + id: r.id, + pubkey: r.pubkey, + createdAt: r.created_at, + content: r.content, + })), + }; - [event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile); - } finally { - pool.close([RELAY_URL]); - } + [event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile); } export function removeReply(id: string) { @@ -194,21 +189,10 @@ export async function sendReply(content: string, ownPubkey: string) { console.log("[reply] event signed:", signed.id); console.log("[reply] publishing…"); - const pool = new SimplePool(); - try { - const timeout = new Promise((_, reject) => - setTimeout( - () => reject(new Error("Relay did not respond in time")), - 8000, - ), - ); - await Promise.race([ - Promise.all(pool.publish([RELAY_URL], signed)), - timeout, - ]); - } finally { - pool.destroy(); - } + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Relay did not respond in time")), 8000), + ); + await Promise.race([Promise.all(publishForum(signed)), timeout]); console.log("[reply] published"); const newReply: PostData = { diff --git a/src/lib/threadRefs.ts b/src/lib/threadRefs.ts index 8aa3759..f96be53 100644 --- a/src/lib/threadRefs.ts +++ b/src/lib/threadRefs.ts @@ -1,5 +1,4 @@ -import { SimplePool } from "@nostr/tools"; -import { RELAY_URL } from "./config"; +import { queryForum } from "./relay"; // A note/nevent that resolves to a forum thread (kind 11) or reply (kind 1111). export type ThreadRef = { @@ -16,12 +15,8 @@ function titleOf(tags: string[][]): string { } async function doResolve(id: string): Promise { - const pool = new SimplePool(); try { - const events = await pool.querySync([RELAY_URL], { - ids: [id], - kinds: [11, 1111], - }); + const events = await queryForum({ ids: [id], kinds: [11, 1111] }); const ev = events[0]; if (!ev) return null; @@ -32,10 +27,7 @@ async function doResolve(id: string): Promise { const root = ev.tags.find((t) => t[0] === "E"); const rootId = root?.[1]; if (!rootId) return null; - const threads = await pool.querySync([RELAY_URL], { - ids: [rootId], - kinds: [11], - }); + const threads = await queryForum({ ids: [rootId], kinds: [11] }); const thread = threads[0]; return { threadId: rootId, @@ -45,8 +37,6 @@ async function doResolve(id: string): Promise { }; } catch { return null; - } finally { - pool.destroy(); } } diff --git a/src/lib/threads.svelte.ts b/src/lib/threads.svelte.ts index 0d66490..9a63419 100644 --- a/src/lib/threads.svelte.ts +++ b/src/lib/threads.svelte.ts @@ -1,8 +1,8 @@ -import { Relay } from "@nostr/tools"; +import type { AbstractRelay } from "@nostr/tools/abstract-relay"; import type { Event } from "@nostr/tools/core"; import type { Filter } from "@nostr/tools/filter"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; -import { RELAY_URL } from "$lib/config"; +import { ensureForumRelay } from "$lib/relay"; import { ingestNostrUser } from "$lib/profiles.svelte"; const PAGE_SIZE = 30; @@ -59,7 +59,7 @@ async function loadProfile(pubkey: string) { ingestNostrUser(user); } -function querySync(relay: Relay, filter: Filter): Promise { +function querySync(relay: AbstractRelay, filter: Filter): Promise { return new Promise((resolve) => { const events: Event[] = []; const sub = relay.subscribe([filter], { @@ -92,7 +92,7 @@ type SliceItem = { // threads not already shown. The first event seen for a thread defines its // activity timestamp. Returns the slice plus the cursor for the next call. async function fetchActivitySlice( - relay: Relay, + relay: AbstractRelay, groupId: string, until: number, exclude: Set, @@ -153,7 +153,7 @@ async function fetchActivitySlice( // data is needed to order them (stable cursor), keeping the path cheap; reply // counts are still attached later via enrichment. async function fetchNewSlice( - relay: Relay, + relay: AbstractRelay, groupId: string, until: number, exclude: Set, @@ -184,7 +184,7 @@ async function fetchNewSlice( // Reply enrichment (exact counts + sampled repliers), bounded by the frozen // snapshot. Isolated so the future creation-by-date view can skip it entirely. async function enrichWithReplies( - relay: Relay, + relay: AbstractRelay, groupId: string, items: SliceItem[], ): Promise> { @@ -223,7 +223,7 @@ async function enrichWithReplies( } async function buildThreads( - relay: Relay, + relay: AbstractRelay, groupId: string, items: SliceItem[], ): Promise { @@ -268,7 +268,7 @@ async function runLoad(append: boolean, groupId: string) { if (append) loadingMore = true; else loading = true; - const relay = await Relay.connect(RELAY_URL); + const relay = await ensureForumRelay(); try { const until = append ? (cursor ?? snapshotAt) : snapshotAt; const exclude = new Set(threads.map((t) => t.id)); @@ -290,7 +290,7 @@ async function runLoad(append: boolean, groupId: string) { for (const p of t.replierPubkeys) loadProfile(p); } } finally { - relay.close(); + // shared forum connection is long-lived — don't close it here if (id === reqId) { if (append) loadingMore = false; else loading = false; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d5b75ee..7528f50 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -22,6 +22,7 @@ import { loadRoomAdmins } from "$lib/admins.svelte"; import { seedProfiles } from "$lib/profiles.svelte"; import { startChat } from "$lib/chat.svelte"; + import { resetForumConnection } from "$lib/relay"; import { activeGroup, setActiveGroup } from "$lib/active.svelte"; import { MODE, ACCENT_COLOR, SECONDARY_COLOR } from "$lib/config"; @@ -72,6 +73,30 @@ seedProfiles(auth.user?.pubkey ?? null); }); + // Reload the room views when the user logs in or out: a logged-in member sees + // their private/hidden rooms, so the listing must be re-fetched over the now + // (de)authenticated connection. sessionEpoch only bumps on explicit + // login/logout, never on the silent restore that onMount already covers. + let lastEpoch = -1; + $effect(() => { + const epoch = auth.sessionEpoch; + if (epoch === lastEpoch) return; + const first = lastEpoch === -1; + const loggedOut = auth.user === null; + lastEpoch = epoch; + if (first) return; // initial mount: onMount already loaded everything + // On logout, drop the authenticated connection so the relay stops serving + // the previous user's private rooms; login reuses the open connection, + // which ensureForumReady authenticates via its stored challenge. + if (loggedOut) resetForumConnection(); + loadGroup(); + if (mode === "full") { + loadGroups().then(() => + loadRoomAdmins(groupsStore.list.map((g) => g.id)), + ); + } + }); + // Full mode: the room route defines the active group. Thread pages set it // themselves from the thread's own group, so only track the slug here. $effect(() => {