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 { 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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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 { 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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue