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 { MODE } from "$lib/config";
import { RELAY_URL, MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { queryForum } from "$lib/relay";
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms. // room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
let byRoom = $state<Record<string, string[]>>({}); let byRoom = $state<Record<string, string[]>>({});
@ -47,9 +47,8 @@ export async function loadRoomAdmins(roomIds: string[]) {
if (key === loadedKey) return; if (key === loadedKey) return;
loadedKey = key; loadedKey = key;
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await queryForum({
kinds: [39001], kinds: [39001],
"#d": roomIds, "#d": roomIds,
}); });
@ -62,6 +61,5 @@ export async function loadRoomAdmins(roomIds: string[]) {
byRoom = map; byRoom = map;
} finally { } finally {
loaded = true; loaded = true;
pool.close([RELAY_URL]);
} }
} }

View file

@ -19,6 +19,10 @@ export type Signer = {
let user = $state<NostrUser | null>(null); let user = $state<NostrUser | null>(null);
let signer = $state<Signer | null>(null); let signer = $state<Signer | null>(null);
let loginModalOpen = $state(false); 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 = { export const auth = {
get user() { get user() {
@ -30,6 +34,9 @@ export const auth = {
get loginModalOpen() { get loginModalOpen() {
return loginModalOpen; return loginModalOpen;
}, },
get sessionEpoch() {
return sessionEpoch;
},
}; };
const PUBKEY_KEY = "nostr_pubkey"; const PUBKEY_KEY = "nostr_pubkey";
@ -89,6 +96,7 @@ export async function loginWithExtension() {
localStorage.setItem(METHOD_KEY, "extension"); localStorage.setItem(METHOD_KEY, "extension");
localStorage.removeItem(NSEC_KEY); localStorage.removeItem(NSEC_KEY);
await setUser(pubkey); await setUser(pubkey);
sessionEpoch++;
} }
function parseSecretKey(input: string): Uint8Array { function parseSecretKey(input: string): Uint8Array {
@ -119,6 +127,7 @@ export async function loginWithNsec(input: string) {
localStorage.setItem(METHOD_KEY, "nsec"); localStorage.setItem(METHOD_KEY, "nsec");
localStorage.setItem(NSEC_KEY, nsec); localStorage.setItem(NSEC_KEY, nsec);
await setUser(pubkey); await setUser(pubkey);
sessionEpoch++;
} }
export function logout() { export function logout() {
@ -128,6 +137,7 @@ export function logout() {
localStorage.removeItem(METHOD_KEY); localStorage.removeItem(METHOD_KEY);
localStorage.removeItem(NSEC_KEY); localStorage.removeItem(NSEC_KEY);
resetJoinState(); resetJoinState();
sessionEpoch++;
} }
export async function restoreSession() { 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 { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { RELAY_URL } from "$lib/config"; import { RELAY_URL } from "$lib/config";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { queryForum, publishForum, subscribeForum } from "$lib/relay";
import { ingestNostrUser } from "$lib/profiles.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte";
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
import { convertForumUrls } from "$lib/linkify"; import { convertForumUrls } from "$lib/linkify";
@ -19,7 +20,6 @@ let messages = $state<ChatMessageData[]>([]);
let profiles = $state<Record<string, NostrUser>>({}); let profiles = $state<Record<string, NostrUser>>({});
let currentGroup: string | null = null; let currentGroup: string | null = null;
let chatReq = 0; // supersedes an in-flight load when the room changes let chatReq = 0; // supersedes an in-flight load when the room changes
let livePool: SimplePool | null = null;
let liveSub: { close(): void } | null = null; let liveSub: { close(): void } | null = null;
export const chatStore = { export const chatStore = {
@ -80,14 +80,11 @@ export async function startChat(groupId: string) {
const req = ++chatReq; const req = ++chatReq;
liveSub?.close(); liveSub?.close();
livePool?.close([RELAY_URL]);
liveSub = null; liveSub = null;
livePool = null;
messages = []; messages = [];
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await queryForum({
kinds: [9], kinds: [9],
"#h": [groupId], "#h": [groupId],
limit: 100, limit: 100,
@ -96,15 +93,11 @@ export async function startChat(groupId: string) {
for (const ev of events) ingestEvent(ev); for (const ev of events) ingestEvent(ev);
} catch (e) { } catch (e) {
console.error("[chat] initial load failed", e); console.error("[chat] initial load failed", e);
} finally {
pool.close([RELAY_URL]);
} }
if (req !== chatReq) return; if (req !== chatReq) return;
livePool = new SimplePool(); liveSub = subscribeForum(
liveSub = livePool.subscribeMany(
[RELAY_URL],
{ {
kinds: [9], kinds: [9],
"#h": [groupId], "#h": [groupId],
@ -117,9 +110,7 @@ export async function startChat(groupId: string) {
export function stopChat() { export function stopChat() {
chatReq++; chatReq++;
liveSub?.close(); liveSub?.close();
livePool?.close([RELAY_URL]);
liveSub = null; liveSub = null;
livePool = null;
currentGroup = null; currentGroup = null;
messages = []; messages = [];
} }
@ -164,21 +155,10 @@ export async function sendChatMessage(
content, content,
}); });
const pool = new SimplePool(); const timeout = new Promise<never>((_, reject) =>
try { setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
const timeout = new Promise<never>((_, reject) => );
setTimeout( await Promise.race([Promise.all(publishForum(signed)), timeout]);
() => reject(new Error("Relay did not respond in time")),
8000,
),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], signed)),
timeout,
]);
} finally {
pool.destroy();
}
ingestEvent(signed); ingestEvent(signed);
} }

View file

@ -1,8 +1,7 @@
import { SimplePool } from "@nostr/tools";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte"; import { withJoin } from "$lib/join.svelte";
import { activeGroup } from "$lib/active.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 { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
import { convertForumUrls } from "$lib/linkify"; import { convertForumUrls } from "$lib/linkify";
@ -139,21 +138,13 @@ export async function publishDraft(): Promise<{
content: c, content: c,
}); });
const pool = new SimplePool(); const timeout = new Promise<never>((_, reject) =>
try { setTimeout(
const timeout = new Promise<never>((_, reject) => () => reject(new Error("Relay did not respond in time")),
setTimeout( 8000,
() => reject(new Error("Relay did not respond in time")), ),
8000, );
), await Promise.race([Promise.all(publishForum(event)), timeout]);
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], event)),
timeout,
]);
} finally {
pool.destroy();
}
threadId = event.id; threadId = event.id;
}); });

View file

@ -1,5 +1,5 @@
import { SimplePool } from "@nostr/tools"; import { GROUP_ID } from "$lib/config";
import { RELAY_URL, GROUP_ID } from "$lib/config"; import { queryForum } from "$lib/relay";
export type GroupMetadata = { export type GroupMetadata = {
name: string; name: string;
@ -23,9 +23,8 @@ export const groupStore = {
}; };
export async function loadGroup() { export async function loadGroup() {
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await queryForum({
kinds: [39000, 39001], kinds: [39000, 39001],
"#d": [GROUP_ID], "#d": [GROUP_ID],
}); });
@ -45,6 +44,5 @@ export async function loadGroup() {
}; };
} finally { } finally {
loaded = true; loaded = true;
pool.close([RELAY_URL]);
} }
} }

View file

@ -1,5 +1,4 @@
import { SimplePool } from "@nostr/tools"; import { queryForum } from "$lib/relay";
import { RELAY_URL } from "$lib/config";
export type GroupSummary = { export type GroupSummary = {
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug 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 // Fetch every group the relay hosts. NIP-29 publishes one kind 39000 metadata
// event per group, so an unfiltered query enumerates them all. // event per group, so an unfiltered query enumerates them all.
export async function loadGroups() { export async function loadGroups() {
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { kinds: [39000] }); const events = await queryForum({ kinds: [39000] });
list = events list = events
.map((e) => { .map((e) => {
const id = e.tags.find((t) => t[0] === "d")?.[1] ?? ""; 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)); .sort((a, b) => a.name.localeCompare(b.name));
} finally { } finally {
loaded = true; 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 { 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 // 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. // 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 // 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. // did not match. Either signal marks the user as joined and skips the 9021.
function queryHasMatch( function queryHasMatch(
relay: Relay, relay: AbstractRelay,
filter: Parameters<Relay["subscribe"]>[0][number], filter: Parameters<AbstractRelay["subscribe"]>[0][number],
timeoutMs = 3000, timeoutMs = 3000,
): Promise<boolean> { ): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -74,31 +75,25 @@ async function checkMembership(
pubkey: string, pubkey: string,
groupId: string, groupId: string,
): Promise<boolean> { ): Promise<boolean> {
let relay: Relay; let relay: AbstractRelay;
try { try {
relay = await Relay.connect(RELAY_URL); relay = await ensureForumRelay();
} catch { } catch {
return false; return false;
} }
try { const has9000 = await queryHasMatch(relay, {
const has9000 = await queryHasMatch(relay, { kinds: [9000],
kinds: [9000], "#h": [groupId],
"#h": [groupId], "#p": [pubkey],
"#p": [pubkey], limit: 1,
limit: 1, });
}); if (has9000) return true;
if (has9000) return true; return await queryHasMatch(relay, {
return await queryHasMatch(relay, { kinds: [39002],
kinds: [39002], "#d": [groupId],
"#d": [groupId], "#p": [pubkey],
"#p": [pubkey], limit: 1,
limit: 1, });
});
} finally {
try {
relay.close();
} catch {}
}
} }
// Simple mode pre-checks the single configured group at login so the first post // 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, tags,
content: "", content: "",
}); });
const pool = new SimplePool(); const timeout = new Promise<never>((_, reject) =>
try { setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
const timeout = new Promise<never>((_, reject) => );
setTimeout( await Promise.race([Promise.all(publishForum(event)), timeout]);
() => reject(new Error("Relay did not respond in time")),
8000,
),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], event)),
timeout,
]);
} finally {
pool.destroy();
}
} }
// Wraps an action that posts to `groupId`. If the user isn't known to be a // 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 { auth } from "$lib/auth.svelte";
import { publishForum } from "$lib/relay";
// What's pending deletion, surfaced to the confirmation modal. `label` is the // What's pending deletion, surfaced to the confirmation modal. `label` is the
// noun shown in the dialog copy ("discussion", "reply", "message"). // noun shown in the dialog copy ("discussion", "reply", "message").
@ -70,21 +69,10 @@ export async function confirmDelete(reason?: string) {
content: reason?.trim() ?? "", content: reason?.trim() ?? "",
}); });
const pool = new SimplePool(); const timeout = new Promise<never>((_, reject) =>
try { setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
const timeout = new Promise<never>((_, reject) => );
setTimeout( await Promise.race([Promise.all(publishForum(signed)), timeout]);
() => reject(new Error("Relay did not respond in time")),
8000,
),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], signed)),
timeout,
]);
} finally {
pool.destroy();
}
onDeleted?.(); onDeleted?.();
target = null; 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 { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter"; import type { Filter } from "@nostr/tools/filter";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; 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"; import { ingestNostrUser } from "$lib/profiles.svelte";
export type RoomActivity = { 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) => { return new Promise((resolve) => {
const events: Event[] = []; const events: Event[] = [];
const sub = relay.subscribe([filter], { const sub = relay.subscribe([filter], {
@ -77,7 +77,7 @@ export async function loadOverview(roomIds: string[]) {
loadedKey = key; loadedKey = key;
loading = true; loading = true;
const relay = await Relay.connect(RELAY_URL); const relay = await ensureForumRelay();
try { try {
// One tiny query per room for its newest event (thread or reply). // One tiny query per room for its newest event (thread or reply).
const latest = await Promise.all( 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 pk of Object.values(adm)) loadProfile(pk);
for (const t of recent) loadProfile(t.authorPubkey); for (const t of recent) loadProfile(t.authorPubkey);
} finally { } finally {
relay.close(); // shared forum connection is long-lived — don't close it here
loading = false; 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 { 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. // Named slots a partial can fill. The NIP-23 `d` tag carries the slot name.
export type PartialSlot = "home" | "contacts"; export type PartialSlot = "home" | "contacts";
@ -40,9 +39,8 @@ export const partialsStore = {
// Partials are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-partial"]; // Partials are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-partial"];
// the `d` tag names the slot the article fills. // the `d` tag names the slot the article fills.
export async function loadPartials() { export async function loadPartials() {
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await queryForum({
kinds: [30023], kinds: [30023],
"#t": ["squalk-partial"], "#t": ["squalk-partial"],
}); });
@ -62,6 +60,5 @@ export async function loadPartials() {
all = next; all = next;
} finally { } finally {
loaded = true; 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 * as nip19 from "@nostr/tools/nip19";
import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { NostrUser } from "@nostr/gadgets/metadata"; 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 = { export type ProfileEntry = {
pubkey: string; pubkey: string;
@ -191,8 +192,9 @@ export function seedProfiles(userPubkey: string | null): Promise<void> {
async function doSeedProfiles(userPubkey: string | null) { async function doSeedProfiles(userPubkey: string | null) {
const pool = new SimplePool(); const pool = new SimplePool();
try { try {
// Group members live on the forum relay (NIP-29). // Group members live on the forum relay (NIP-29); query it through the
const groupEvents = await pool.querySync([RELAY_URL], { // shared authenticated connection so private-group members are included.
const groupEvents = await queryForum({
kinds: [39002], kinds: [39002],
"#d": [GROUP_ID], "#d": [GROUP_ID],
limit: 1, limit: 1,
@ -267,7 +269,7 @@ async function doSeedProfiles(userPubkey: string | null) {
localStorage.setItem(LAST_SYNC_KEY, String(Math.floor(Date.now() / 1000))); localStorage.setItem(LAST_SYNC_KEY, String(Math.floor(Date.now() / 1000)));
} finally { } 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 { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay";
export type Resource = { export type Resource = {
id: string; 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"]. // Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"].
export async function loadResources() { export async function loadResources() {
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await queryForum({
kinds: [30023], kinds: [30023],
"#t": ["squalk-resource"], "#t": ["squalk-resource"],
}); });
@ -69,6 +67,5 @@ export async function loadResources() {
all = [...bySlug.values()].sort(compare); all = [...bySlug.values()].sort(compare);
} finally { } finally {
loaded = true; 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 { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { RELAY_URL, GROUP_ID } from "$lib/config"; import { RELAY_URL, GROUP_ID } from "$lib/config";
import { queryForum, publishForum } from "$lib/relay";
import { threads as mockThreads } from "$lib/mock"; import { threads as mockThreads } from "$lib/mock";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { ingestNostrUser } from "$lib/profiles.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte";
@ -96,41 +96,36 @@ export async function loadThread(id: string) {
return; return;
} }
const pool = new SimplePool(); const [threadEvents, replyEvents] = await Promise.all([
try { queryForum({ kinds: [11], ids: [id] }),
const [threadEvents, replyEvents] = await Promise.all([ queryForum({ kinds: [1111], "#E": [id] }),
pool.querySync([RELAY_URL], { kinds: [11], ids: [id] }), ]);
pool.querySync([RELAY_URL], { kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0]; const event = threadEvents[0];
if (!event) return; 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, id: event.id,
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", pubkey: event.pubkey,
labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]), createdAt: event.created_at,
groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID, content: event.content,
op: { },
id: event.id, replies: replies.map((r) => ({
pubkey: event.pubkey, id: r.id,
createdAt: event.created_at, pubkey: r.pubkey,
content: event.content, createdAt: r.created_at,
}, content: r.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); [event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
} finally {
pool.close([RELAY_URL]);
}
} }
export function removeReply(id: string) { 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] event signed:", signed.id);
console.log("[reply] publishing…"); console.log("[reply] publishing…");
const pool = new SimplePool(); const timeout = new Promise<never>((_, reject) =>
try { setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
const timeout = new Promise<never>((_, reject) => );
setTimeout( await Promise.race([Promise.all(publishForum(signed)), timeout]);
() => reject(new Error("Relay did not respond in time")),
8000,
),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], signed)),
timeout,
]);
} finally {
pool.destroy();
}
console.log("[reply] published"); console.log("[reply] published");
const newReply: PostData = { const newReply: PostData = {

View file

@ -1,5 +1,4 @@
import { SimplePool } from "@nostr/tools"; import { queryForum } from "./relay";
import { RELAY_URL } from "./config";
// A note/nevent that resolves to a forum thread (kind 11) or reply (kind 1111). // A note/nevent that resolves to a forum thread (kind 11) or reply (kind 1111).
export type ThreadRef = { export type ThreadRef = {
@ -16,12 +15,8 @@ function titleOf(tags: string[][]): string {
} }
async function doResolve(id: string): Promise<ThreadRef | null> { async function doResolve(id: string): Promise<ThreadRef | null> {
const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await queryForum({ ids: [id], kinds: [11, 1111] });
ids: [id],
kinds: [11, 1111],
});
const ev = events[0]; const ev = events[0];
if (!ev) return null; 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 root = ev.tags.find((t) => t[0] === "E");
const rootId = root?.[1]; const rootId = root?.[1];
if (!rootId) return null; if (!rootId) return null;
const threads = await pool.querySync([RELAY_URL], { const threads = await queryForum({ ids: [rootId], kinds: [11] });
ids: [rootId],
kinds: [11],
});
const thread = threads[0]; const thread = threads[0];
return { return {
threadId: rootId, threadId: rootId,
@ -45,8 +37,6 @@ async function doResolve(id: string): Promise<ThreadRef | null> {
}; };
} catch { } catch {
return null; 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 { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter"; import type { Filter } from "@nostr/tools/filter";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; 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"; import { ingestNostrUser } from "$lib/profiles.svelte";
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
@ -59,7 +59,7 @@ async function loadProfile(pubkey: string) {
ingestNostrUser(user); ingestNostrUser(user);
} }
function querySync(relay: Relay, filter: Filter): Promise<Event[]> { function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
const events: Event[] = []; const events: Event[] = [];
const sub = relay.subscribe([filter], { const sub = relay.subscribe([filter], {
@ -92,7 +92,7 @@ type SliceItem = {
// threads not already shown. The first event seen for a thread defines its // 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. // activity timestamp. Returns the slice plus the cursor for the next call.
async function fetchActivitySlice( async function fetchActivitySlice(
relay: Relay, relay: AbstractRelay,
groupId: string, groupId: string,
until: number, until: number,
exclude: Set<string>, exclude: Set<string>,
@ -153,7 +153,7 @@ async function fetchActivitySlice(
// data is needed to order them (stable cursor), keeping the path cheap; reply // data is needed to order them (stable cursor), keeping the path cheap; reply
// counts are still attached later via enrichment. // counts are still attached later via enrichment.
async function fetchNewSlice( async function fetchNewSlice(
relay: Relay, relay: AbstractRelay,
groupId: string, groupId: string,
until: number, until: number,
exclude: Set<string>, exclude: Set<string>,
@ -184,7 +184,7 @@ async function fetchNewSlice(
// Reply enrichment (exact counts + sampled repliers), bounded by the frozen // Reply enrichment (exact counts + sampled repliers), bounded by the frozen
// snapshot. Isolated so the future creation-by-date view can skip it entirely. // snapshot. Isolated so the future creation-by-date view can skip it entirely.
async function enrichWithReplies( async function enrichWithReplies(
relay: Relay, relay: AbstractRelay,
groupId: string, groupId: string,
items: SliceItem[], items: SliceItem[],
): Promise<Map<string, { count: number; repliers: string[] }>> { ): Promise<Map<string, { count: number; repliers: string[] }>> {
@ -223,7 +223,7 @@ async function enrichWithReplies(
} }
async function buildThreads( async function buildThreads(
relay: Relay, relay: AbstractRelay,
groupId: string, groupId: string,
items: SliceItem[], items: SliceItem[],
): Promise<ThreadData[]> { ): Promise<ThreadData[]> {
@ -268,7 +268,7 @@ async function runLoad(append: boolean, groupId: string) {
if (append) loadingMore = true; if (append) loadingMore = true;
else loading = true; else loading = true;
const relay = await Relay.connect(RELAY_URL); const relay = await ensureForumRelay();
try { try {
const until = append ? (cursor ?? snapshotAt) : snapshotAt; const until = append ? (cursor ?? snapshotAt) : snapshotAt;
const exclude = new Set(threads.map((t) => t.id)); 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); for (const p of t.replierPubkeys) loadProfile(p);
} }
} finally { } finally {
relay.close(); // shared forum connection is long-lived — don't close it here
if (id === reqId) { if (id === reqId) {
if (append) loadingMore = false; if (append) loadingMore = false;
else loading = false; else loading = false;

View file

@ -22,6 +22,7 @@
import { loadRoomAdmins } from "$lib/admins.svelte"; import { loadRoomAdmins } from "$lib/admins.svelte";
import { seedProfiles } from "$lib/profiles.svelte"; import { seedProfiles } from "$lib/profiles.svelte";
import { startChat } from "$lib/chat.svelte"; import { startChat } from "$lib/chat.svelte";
import { resetForumConnection } from "$lib/relay";
import { activeGroup, setActiveGroup } from "$lib/active.svelte"; import { activeGroup, setActiveGroup } from "$lib/active.svelte";
import { MODE, ACCENT_COLOR, SECONDARY_COLOR } from "$lib/config"; import { MODE, ACCENT_COLOR, SECONDARY_COLOR } from "$lib/config";
@ -72,6 +73,30 @@
seedProfiles(auth.user?.pubkey ?? null); 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 // 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. // themselves from the thread's own group, so only track the slug here.
$effect(() => { $effect(() => {