Authenticate over NIP-42 so members see hidden rooms + use one shared SimplePool
This commit is contained in:
parent
6377d00268
commit
d965cdef01
17 changed files with 251 additions and 204 deletions
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
106
src/lib/relay.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue