Authenticate over NIP-42 so members see hidden rooms + use one shared SimplePool

This commit is contained in:
dtonon 2026-06-09 13:00:46 +01:00
parent 6377d00268
commit d965cdef01
17 changed files with 251 additions and 204 deletions

View file

@ -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<Record<string, string[]>>({});
@ -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]);
}
}

View file

@ -19,6 +19,10 @@ export type Signer = {
let user = $state<NostrUser | null>(null);
let signer = $state<Signer | null>(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() {

View file

@ -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<ChatMessageData[]>([]);
let profiles = $state<Record<string, NostrUser>>({});
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<never>((_, reject) =>
setTimeout(
() => reject(new Error("Relay did not respond in time")),
8000,
),
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();
}
await Promise.race([Promise.all(publishForum(signed)), timeout]);
ingestEvent(signed);
}

View file

@ -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<never>((_, 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();
}
await Promise.race([Promise.all(publishForum(event)), timeout]);
threadId = event.id;
});

View file

@ -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]);
}
}

View file

@ -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]);
}
}

View file

@ -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<Relay["subscribe"]>[0][number],
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
timeoutMs = 3000,
): Promise<boolean> {
return new Promise((resolve) => {
@ -74,13 +75,12 @@ async function checkMembership(
pubkey: string,
groupId: string,
): Promise<boolean> {
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],
@ -94,11 +94,6 @@ async function checkMembership(
"#p": [pubkey],
limit: 1,
});
} finally {
try {
relay.close();
} catch {}
}
}
// 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<never>((_, reject) =>
setTimeout(
() => reject(new Error("Relay did not respond in time")),
8000,
),
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();
}
await Promise.race([Promise.all(publishForum(event)), timeout]);
}
// Wraps an action that posts to `groupId`. If the user isn't known to be a

View file

@ -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<never>((_, reject) =>
setTimeout(
() => reject(new Error("Relay did not respond in time")),
8000,
),
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();
}
await Promise.race([Promise.all(publishForum(signed)), timeout]);
onDeleted?.();
target = null;

View file

@ -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<Event[]> {
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
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;
}
}

View file

@ -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]);
}
}

View file

@ -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<void> {
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);
}
}

106
src/lib/relay.ts Normal file
View file

@ -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<void> {
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<Event[]> {
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<string>[] {
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<SimplePool["subscribeMany"]>[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<AbstractRelay> {
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]);
}

View file

@ -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]);
}
}

View file

@ -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,11 +96,9 @@ 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] }),
queryForum({ kinds: [11], ids: [id] }),
queryForum({ kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0];
@ -128,9 +126,6 @@ export async function loadThread(id: string) {
};
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
} finally {
pool.close([RELAY_URL]);
}
}
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<never>((_, reject) =>
setTimeout(
() => reject(new Error("Relay did not respond in time")),
8000,
),
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();
}
await Promise.race([Promise.all(publishForum(signed)), timeout]);
console.log("[reply] published");
const newReply: PostData = {

View file

@ -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<ThreadRef | null> {
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<ThreadRef | null> {
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<ThreadRef | null> {
};
} catch {
return null;
} finally {
pool.destroy();
}
}

View file

@ -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<Event[]> {
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
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<string>,
@ -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<string>,
@ -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<Map<string, { count: number; repliers: string[] }>> {
@ -223,7 +223,7 @@ async function enrichWithReplies(
}
async function buildThreads(
relay: Relay,
relay: AbstractRelay,
groupId: string,
items: SliceItem[],
): Promise<ThreadData[]> {
@ -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;

View file

@ -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(() => {