Extract relay-agnostic forum fetchers from the stores

This commit is contained in:
dtonon 2026-08-14 22:27:04 +01:00
parent 54c0682dce
commit a7b5d8f223
16 changed files with 694 additions and 531 deletions

View file

@ -1,6 +1,7 @@
import { MODE } from "$lib/config"; import { MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { fetchRoomAdmins } from "$lib/forum/groups";
// 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[]>>({});
@ -48,17 +49,7 @@ export async function loadRoomAdmins(roomIds: string[]) {
loadedKey = key; loadedKey = key;
try { try {
const events = await queryForum({ byRoom = await fetchRoomAdmins(queryForum, roomIds);
kinds: [39001],
"#d": roomIds,
});
const map: Record<string, string[]> = {};
for (const e of events) {
const d = e.tags.find((t) => t[0] === "d")?.[1];
if (!d) continue;
map[d] = e.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);
}
byRoom = map;
} finally { } finally {
loaded = true; loaded = true;
} }

81
src/lib/forum/groups.ts Normal file
View file

@ -0,0 +1,81 @@
import type { Event } from "@nostr/tools/core";
import { tag, tags, type Query } from "./query";
export type GroupSummary = {
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
name: string;
picture?: string;
about?: string;
createdAt: number;
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
};
export type GroupMetadata = {
name: string;
picture?: string;
about?: string;
isPrivate: boolean;
isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
admins: string[];
};
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
function parseSummary(e: Event): GroupSummary {
const id = tag(e, "d") ?? "";
return {
id,
name: tag(e, "name") ?? id,
picture: tag(e, "picture"),
about: tag(e, "about"),
createdAt: e.created_at,
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
};
}
// Every group the relay serves this visitor (kind 39000), oldest first.
export async function fetchGroups(q: Query): Promise<GroupSummary[]> {
const events = await q({ kinds: [39000] });
return events
.map(parseSummary)
.filter((g) => g.id)
.sort((a, b) => a.createdAt - b.createdAt);
}
// One group's metadata plus its admins (kinds 39000 + 39001).
export async function fetchGroup(
q: Query,
id: string,
): Promise<GroupMetadata | null> {
const events = await q({ kinds: [39000, 39001], "#d": [id] });
const event = events.find((e) => e.kind === 39000);
if (!event) return null;
const adminsEvent = events.find((e) => e.kind === 39001);
return {
name: tag(event, "name") ?? id,
picture: tag(event, "picture"),
about: tag(event, "about"),
isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"),
isRestricted: event.tags.some((t) => t[0] === "restricted"),
isHidden: event.tags.some((t) => t[0] === "hidden"),
admins: adminsEvent ? tags(adminsEvent, "p") : [],
};
}
// room id -> admin pubkeys (kind 39001 `p` tags) for the given rooms.
export async function fetchRoomAdmins(
q: Query,
roomIds: string[],
): Promise<Record<string, string[]>> {
if (roomIds.length === 0) return {};
const events = await q({ kinds: [39001], "#d": roomIds });
const map: Record<string, string[]> = {};
for (const e of events) {
const d = tag(e, "d");
if (d) map[d] = tags(e, "p");
}
return map;
}

73
src/lib/forum/overview.ts Normal file
View file

@ -0,0 +1,73 @@
import { tag, type Query } from "./query";
export type RoomActivity = {
latestAt: number;
latestPubkey: string;
};
export type RecentThread = {
id: string;
title: string;
groupId: string;
authorPubkey: string;
createdAt: number;
};
export type Overview = {
activity: Record<string, RoomActivity>;
admins: Record<string, string>; // room id -> first admin pubkey
recent: RecentThread[];
};
const RECENT_LIMIT = 20;
// Landing data across rooms: each room's last activity and first admin (for
// the room cards) and the most recent threads overall (for the side panel).
// The group relay truncates multi-value "#h" filters, so rooms are queried
// one by one and merged.
export async function fetchOverview(
q: Query,
roomIds: string[],
): Promise<Overview> {
const latest = await Promise.all(
roomIds.map((id) => q({ kinds: [11, 1111], "#h": [id], limit: 1 })),
);
const activity: Record<string, RoomActivity> = {};
roomIds.forEach((id, i) => {
const e = latest[i][0];
if (e) activity[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
});
const adminEvents = await Promise.all(
roomIds.map((id) => q({ kinds: [39001], "#d": [id] })),
);
const admins: Record<string, string> = {};
roomIds.forEach((id, i) => {
const pk = adminEvents[i][0] ? tag(adminEvents[i][0], "p") : undefined;
if (pk) admins[id] = pk;
});
const perRoom = await Promise.all(
roomIds.map((id) => q({ kinds: [11], "#h": [id], limit: RECENT_LIMIT })),
);
const threads = perRoom.flat().sort((a, b) => b.created_at - a.created_at);
const recent = threads.slice(0, RECENT_LIMIT).map((e) => ({
id: e.id,
title: tag(e, "title") ?? "(untitled)",
groupId: tag(e, "h") ?? "",
authorPubkey: e.pubkey,
createdAt: e.created_at,
}));
return { activity, admins, recent };
}
export function overviewPubkeys(o: Overview): string[] {
return [
...new Set([
...Object.values(o.activity).map((a) => a.latestPubkey),
...Object.values(o.admins),
...o.recent.map((t) => t.authorPubkey),
]),
];
}

48
src/lib/forum/partials.ts Normal file
View file

@ -0,0 +1,48 @@
import { tag, type Query } from "./query";
export type PartialSlot = "home" | "contacts";
const SLOTS = new Set<string>(["home", "contacts"]);
export type Partial = {
id: string;
slot: PartialSlot;
title: string;
content: string; // markdown body
pubkey: string;
createdAt: number;
};
// Every `squalk-partial` event for a known slot. Not admin-filtered nor
// reduced to the newest per slot: both depend on the admin set.
export async function fetchPartials(q: Query): Promise<Partial[]> {
const events = await q({ kinds: [30023], "#t": ["squalk-partial"] });
const out: Partial[] = [];
for (const e of events) {
const slot = tag(e, "d");
if (!slot || !SLOTS.has(slot)) continue;
out.push({
id: e.id,
slot: slot as PartialSlot,
title: tag(e, "title") ?? slot,
content: e.content,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
return out;
}
// The newest admin-authored partial for a slot.
export function pickPartial(
all: Partial[],
slot: PartialSlot,
admins: string[],
): Partial | undefined {
const set = new Set(admins);
let best: Partial | undefined;
for (const p of all) {
if (p.slot !== slot || !set.has(p.pubkey)) continue;
if (!best || p.createdAt > best.createdAt) best = p;
}
return best;
}

49
src/lib/forum/profiles.ts Normal file
View file

@ -0,0 +1,49 @@
import type { Event } from "@nostr/tools/core";
import * as nip19 from "@nostr/tools/nip19";
import type { NostrUser } from "$lib/gadgets";
import type { Query } from "./query";
// Same shape @nostr/gadgets produces, built here from raw kind-0 events so the
// server can resolve authors without that (browser-only) library.
export function bareUser(pubkey: string): NostrUser {
const npub = nip19.npubEncode(pubkey);
return {
pubkey,
npub,
shortName: npub.substring(0, 8) + "…" + npub.substring(59),
metadata: {},
lastUpdated: 0,
};
}
export function userFromEvent(e: Event): NostrUser {
const u = bareUser(e.pubkey);
let md: NostrUser["metadata"] = {};
try {
md = JSON.parse(e.content) ?? {};
} catch {
// Malformed metadata: keep the bare user
}
u.metadata = md;
u.shortName =
md.name || md.display_name || md.nip05?.split("@")?.[0] || u.shortName;
u.lastUpdated = e.created_at;
if (md.picture) u.image = md.picture;
return u;
}
// Newest kind 0 per pubkey; pubkeys with no metadata are left out.
export async function fetchProfiles(
q: Query,
pubkeys: string[],
): Promise<Record<string, NostrUser>> {
const out: Record<string, NostrUser> = {};
if (pubkeys.length === 0) return out;
const events = await q({ kinds: [0], authors: pubkeys });
for (const e of events) {
const prev = out[e.pubkey];
if (prev && prev.lastUpdated >= e.created_at) continue;
out[e.pubkey] = userFromEvent(e);
}
return out;
}

36
src/lib/forum/query.ts Normal file
View file

@ -0,0 +1,36 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
// A relay read. The fetchers in this folder only depend on this signature, so
// the same code runs behind the browser's authenticated connection and the
// server's anonymous one.
export type Query = (filter: Filter) => Promise<Event[]>;
// Drive a subscription on an already-open relay and resolve at EOSE.
export function relayQuery(relay: AbstractRelay): Query {
return (filter) =>
new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
export function tag(e: Event, name: string): string | undefined {
return e.tags.find((t) => t[0] === name)?.[1];
}
export function tags(e: Event, name: string): string[] {
return e.tags.filter((t) => t[0] === name && t[1]).map((t) => t[1]);
}

View file

@ -0,0 +1,47 @@
import { tag, type Query } from "./query";
export type Resource = {
id: string;
slug: string; // NIP-23 `d` tag — also the resource URL slug
title: string;
content: string; // markdown body
position?: number;
pubkey: string;
createdAt: number;
};
// Positioned resources first (ascending), then alphabetical by title.
function compare(a: Resource, b: Resource): number {
const ap = a.position;
const bp = b.position;
if (ap !== undefined && bp !== undefined)
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
if (ap !== undefined) return -1;
if (bp !== undefined) return 1;
return a.title.localeCompare(b.title);
}
// Every `squalk-resource` article, newest per slug. Not admin-filtered: the
// caller applies the admin set, which may resolve later than this fetch.
export async function fetchResources(q: Query): Promise<Resource[]> {
const events = await q({ kinds: [30023], "#t": ["squalk-resource"] });
const bySlug = new Map<string, Resource>();
for (const e of events) {
const slug = tag(e, "d");
if (!slug) continue;
const existing = bySlug.get(slug);
if (existing && existing.createdAt >= e.created_at) continue;
const posTag = tag(e, "position");
const pos = posTag !== undefined ? Number(posTag) : NaN;
bySlug.set(slug, {
id: e.id,
slug,
title: tag(e, "title") ?? slug,
content: e.content,
position: Number.isFinite(pos) ? pos : undefined,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
return [...bySlug.values()].sort(compare);
}

56
src/lib/forum/thread.ts Normal file
View file

@ -0,0 +1,56 @@
import { tag, tags, type Query } from "./query";
export type PostData = {
id: string;
pubkey: string;
createdAt: number;
content: string;
};
export type ThreadDetail = {
id: string;
title: string;
labels: string[];
groupId: string; // the thread's NIP-29 group (its `h` tag)
op: PostData;
replies: PostData[];
};
// A thread (kind 11) with its flat replies (kind 1111), oldest first. Null
// when the relay returns nothing: no such thread, or one the visitor can't
// read. `fallbackGroup` covers OPs missing an `h` tag.
export async function fetchThread(
q: Query,
id: string,
fallbackGroup = "",
): Promise<ThreadDetail | null> {
const [threadEvents, replyEvents] = await Promise.all([
q({ kinds: [11], ids: [id] }),
q({ kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0];
if (!event) return null;
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
return {
id: event.id,
title: tag(event, "title") ?? "(untitled)",
labels: tags(event, "t"),
groupId: tag(event, "h") ?? fallbackGroup,
op: {
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
},
replies: replies.map((r) => ({
id: r.id,
pubkey: r.pubkey,
createdAt: r.created_at,
content: r.content,
})),
};
}
export function threadAuthors(t: ThreadDetail): string[] {
return [...new Set([t.op.pubkey, ...t.replies.map((r) => r.pubkey)])];
}

238
src/lib/forum/threads.ts Normal file
View file

@ -0,0 +1,238 @@
import type { Event } from "@nostr/tools/core";
import { tag, tags, type Query } from "./query";
export const PAGE_SIZE = 30;
const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE)
const WALK_MAX_ITERS = 12;
export type SortMode = "active" | "new";
export type ThreadData = {
id: string;
title: string;
labels: string[];
authorPubkey: string;
createdAt: number;
replyCount: number;
latestAt: number;
latestPubkey: string;
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
};
export type ThreadPage = {
threads: ThreadData[];
nextCursor: number | null; // sort-key value of the last thread
done: boolean;
};
export type PageRequest = {
sort: SortMode;
until: number; // upper bound on the sort key (inclusive)
snapshotAt: number; // upper time bound for reply counts, frozen per listing
exclude?: Set<string>; // thread ids already shown
n?: number;
};
type SliceItem = {
id: string;
latestAt: number;
latestPubkey: string;
op?: Event;
};
type Slice = { items: SliceItem[]; nextCursor: number | null; done: boolean };
function threadIdOf(e: Event): string | undefined {
return e.kind === 11 ? e.id : tag(e, "E");
}
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
// threads not already shown. The first event seen for a thread defines its
// activity timestamp.
async function fetchActivitySlice(
q: Query,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<Slice> {
const collected = new Map<string, SliceItem>();
let cur = until;
let done = false;
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
const events = await q({
kinds: [11, 1111],
"#h": [groupId],
until: cur,
limit: WALK_LIMIT,
});
if (events.length === 0) {
done = true;
break;
}
events.sort((a, b) => b.created_at - a.created_at);
let oldest = cur;
for (const e of events) {
oldest = Math.min(oldest, e.created_at);
const id = threadIdOf(e);
if (!id || exclude.has(id)) continue;
const seen = collected.get(id);
if (seen) {
// Grab the OP from the stream when a thread was first seen via a reply,
// so we avoid the by-id backfill the group relay truncates
if (!seen.op && e.kind === 11) seen.op = e;
continue;
}
if (collected.size >= n) continue; // Page full; keep scanning for OPs
collected.set(id, {
id,
latestAt: e.created_at,
latestPubkey: e.pubkey,
op: e.kind === 11 ? e : undefined,
});
}
if (events.length < WALK_LIMIT) {
done = true;
break;
}
if (oldest >= cur) break; // No progress (single timestamp floods the window)
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
}
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
return { items, nextCursor, done };
}
// Chronological-by-creation slice: just OPs ordered by created_at. No reply
// data is needed to order them (stable cursor), keeping the path cheap; reply
// counts are still attached later via enrichment.
async function fetchNewSlice(
q: Query,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<Slice> {
const ops = await q({
kinds: [11],
"#h": [groupId],
until,
limit: n + 10, // headroom for boundary OPs re-read at the inclusive cursor
});
ops.sort((a, b) => b.created_at - a.created_at);
const fresh = ops.filter((e) => !exclude.has(e.id));
const slice = fresh.slice(0, n);
const items: SliceItem[] = slice.map((op) => ({
id: op.id,
latestAt: op.created_at,
latestPubkey: op.pubkey,
op,
}));
const done = ops.length < n + 10;
const last = slice[slice.length - 1] ?? ops[ops.length - 1];
const nextCursor = last ? last.created_at : null;
return { items, nextCursor, done };
}
async function enrichWithReplies(
q: Query,
groupId: string,
items: SliceItem[],
snapshotAt: number,
): Promise<Map<string, { count: number; repliers: string[] }>> {
const result = new Map<string, { count: number; repliers: string[] }>();
if (items.length === 0) return result;
const replies = await q({
kinds: [1111],
"#h": [groupId],
"#E": items.map((it) => it.id),
until: snapshotAt,
limit: 5000,
});
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
for (const r of replies) {
const root = tag(r, "E");
if (!root) continue;
let a = acc.get(root);
if (!a) {
a = { count: 0, pubkeys: new Set() };
acc.set(root, a);
}
a.count++;
a.pubkeys.add(r.pubkey);
}
for (const it of items) {
const a = acc.get(it.id);
const repliers = a
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
: [];
result.set(it.id, { count: a?.count ?? 0, repliers });
}
return result;
}
async function buildThreads(
q: Query,
groupId: string,
items: SliceItem[],
snapshotAt: number,
): Promise<ThreadData[]> {
const missing = items.filter((it) => !it.op);
if (missing.length > 0) {
const fetched = await Promise.all(
missing.map((it) => q({ kinds: [11], "#h": [groupId], ids: [it.id] })),
);
const byId = new Map<string, Event>();
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
for (const it of items) if (!it.op) it.op = byId.get(it.id);
}
const enriched = await enrichWithReplies(q, groupId, items, snapshotAt);
const out: ThreadData[] = [];
for (const it of items) {
const op = it.op;
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
const e = enriched.get(it.id);
out.push({
id: it.id,
title: tag(op, "title") ?? "(untitled)",
labels: tags(op, "t"),
authorPubkey: op.pubkey,
createdAt: op.created_at,
replyCount: e?.count ?? 0,
latestAt: it.latestAt,
latestPubkey: it.latestPubkey,
replierPubkeys: e?.repliers ?? [],
});
}
return out;
}
// One page of a group's thread listing, in the given sort order.
export async function fetchThreadPage(
q: Query,
groupId: string,
req: PageRequest,
): Promise<ThreadPage> {
const exclude = req.exclude ?? new Set<string>();
const n = req.n ?? PAGE_SIZE;
const slice =
req.sort === "new"
? await fetchNewSlice(q, groupId, req.until, exclude, n)
: await fetchActivitySlice(q, groupId, req.until, exclude, n);
const threads = await buildThreads(q, groupId, slice.items, req.snapshotAt);
return { threads, nextCursor: slice.nextCursor, done: slice.done };
}
export function threadPagePubkeys(threads: ThreadData[]): string[] {
const set = new Set<string>();
for (const t of threads) {
set.add(t.authorPubkey);
set.add(t.latestPubkey);
for (const p of t.replierPubkeys) set.add(p);
}
return [...set];
}

View file

@ -1,20 +1,10 @@
import { GROUP_ID, MODE } from "$lib/config"; import { GROUP_ID, MODE } from "$lib/config";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { groupsStore } from "$lib/groups.svelte"; import { groupsStore } from "$lib/groups.svelte";
import { fetchGroup, type GroupMetadata } from "$lib/forum/groups";
export type GroupMetadata = { export type { GroupMetadata };
name: string;
picture?: string;
about?: string;
isPrivate: boolean;
isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
admins: string[];
};
// NIP-29 access flags: private = members-only read, restricted = members-only
// write, closed = join requests ignored, hidden = metadata hidden from non-members.
export type GroupFlags = { export type GroupFlags = {
isPrivate: boolean; isPrivate: boolean;
isClosed: boolean; isClosed: boolean;
@ -36,33 +26,12 @@ export const groupStore = {
export async function loadGroup() { export async function loadGroup() {
try { try {
const events = await queryForum({ group = (await fetchGroup(queryForum, GROUP_ID)) ?? group;
kinds: [39000, 39001],
"#d": [GROUP_ID],
});
const event = events.find((e) => e.kind === 39000);
if (!event) return;
// Admins live in the NIP-29 kind 39001 event as `p` tags.
const adminsEvent = events.find((e) => e.kind === 39001);
const admins =
adminsEvent?.tags.filter((t) => t[0] === "p").map((t) => t[1]) ?? [];
group = {
name: event.tags.find((t) => t[0] === "name")?.[1] ?? GROUP_ID,
picture: event.tags.find((t) => t[0] === "picture")?.[1],
about: event.tags.find((t) => t[0] === "about")?.[1],
isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"),
isRestricted: event.tags.some((t) => t[0] === "restricted"),
isHidden: event.tags.some((t) => t[0] === "hidden"),
admins,
};
} finally { } finally {
loaded = true; loaded = true;
} }
} }
// A group's access flags from whichever store holds them: full mode keeps every
// room in the groups list, simple mode has the single active group's metadata.
export function getGroupFlags(groupId: string): GroupFlags | null { export function getGroupFlags(groupId: string): GroupFlags | null {
if (MODE === "full") { if (MODE === "full") {
const g = groupsStore.list.find((x) => x.id === groupId); const g = groupsStore.list.find((x) => x.id === groupId);

View file

@ -1,16 +1,7 @@
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { fetchGroups, type GroupSummary } from "$lib/forum/groups";
export type GroupSummary = { export type { GroupSummary };
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
name: string;
picture?: string;
about?: string;
createdAt: number;
flags: string[]; // special NIP-29 markers present (private, hidden, closed, restricted)
};
// NIP-29 metadata markers we surface as room tags, in display order.
const SPECIAL_FLAGS = ["private", "hidden", "closed", "restricted"];
let list = $state<GroupSummary[]>([]); let list = $state<GroupSummary[]>([]);
let loaded = $state(false); let loaded = $state(false);
@ -24,26 +15,9 @@ 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() { export async function loadGroups() {
try { try {
const events = await queryForum({ kinds: [39000] }); list = await fetchGroups(queryForum);
list = events
.map((e) => {
const id = e.tags.find((t) => t[0] === "d")?.[1] ?? "";
return {
id,
name: e.tags.find((t) => t[0] === "name")?.[1] ?? id,
picture: e.tags.find((t) => t[0] === "picture")?.[1],
about: e.tags.find((t) => t[0] === "about")?.[1],
createdAt: e.created_at,
flags: SPECIAL_FLAGS.filter((f) => e.tags.some((t) => t[0] === f)),
};
})
.filter((g) => g.id)
// Oldest first.
.sort((a, b) => a.createdAt - b.createdAt);
} finally { } finally {
loaded = true; loaded = true;
} }

View file

@ -1,22 +1,15 @@
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 "$lib/gadgets"; import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { ensureForumRelay } from "$lib/relay"; import { ensureForumRelay } from "$lib/relay";
import { ingestNostrUser } from "$lib/profiles.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte";
import { relayQuery } from "$lib/forum/query";
import {
fetchOverview,
overviewPubkeys,
type RecentThread,
type RoomActivity,
} from "$lib/forum/overview";
export type RoomActivity = { export type { RecentThread, RoomActivity };
latestAt: number;
latestPubkey: string;
};
export type RecentThread = {
id: string;
title: string;
groupId: string;
authorPubkey: string;
createdAt: number;
};
let activity = $state<Record<string, RoomActivity>>({}); let activity = $state<Record<string, RoomActivity>>({});
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
@ -43,24 +36,6 @@ export const overviewStore = {
}, },
}; };
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
return new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
async function loadProfile(pubkey: string) { async function loadProfile(pubkey: string) {
if (profiles[pubkey]) return; if (profiles[pubkey]) return;
const user = await loadNostrUser(pubkey); const user = await loadNostrUser(pubkey);
@ -77,54 +52,14 @@ export async function loadOverview(roomIds: string[]) {
loadedKey = key; loadedKey = key;
loading = true; loading = true;
const relay = await ensureForumRelay();
try { try {
// One tiny query per room for its newest event (thread or reply). const q = relayQuery(await ensureForumRelay());
const latest = await Promise.all( const o = await fetchOverview(q, roomIds);
roomIds.map((id) => activity = o.activity;
querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }), admins = o.admins;
), recent = o.recent;
); overviewPubkeys(o).forEach(loadProfile);
const act: Record<string, RoomActivity> = {};
roomIds.forEach((id, i) => {
const e = latest[i][0];
if (e) act[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
});
activity = act;
// Each room's admin (NIP-29 kind 39001, first `p` tag) for the card byline.
const adminEvents = await Promise.all(
roomIds.map((id) => querySync(relay, { kinds: [39001], "#d": [id] })),
);
const adm: Record<string, string> = {};
roomIds.forEach((id, i) => {
const pk = adminEvents[i][0]?.tags.find((t) => t[0] === "p")?.[1];
if (pk) adm[id] = pk;
});
admins = adm;
// Most recent discussions (thread OPs) across all rooms. The group relay
// truncates multi-value "#h" filters, so query each room and merge.
const perRoom = await Promise.all(
roomIds.map((id) =>
querySync(relay, { kinds: [11], "#h": [id], limit: 20 }),
),
);
const threads = perRoom.flat();
threads.sort((a, b) => b.created_at - a.created_at);
recent = threads.slice(0, 20).map((e) => ({
id: e.id,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
groupId: e.tags.find((t) => t[0] === "h")?.[1] ?? "",
authorPubkey: e.pubkey,
createdAt: e.created_at,
}));
for (const a of Object.values(act)) loadProfile(a.latestPubkey);
for (const pk of Object.values(adm)) loadProfile(pk);
for (const t of recent) loadProfile(t.authorPubkey);
} finally { } finally {
// shared forum connection is long-lived — don't close it here
loading = false; loading = false;
} }
} }

View file

@ -1,63 +1,29 @@
import { adminPubkeys } from "$lib/admins.svelte"; import { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import {
fetchPartials,
pickPartial,
type Partial,
type PartialSlot,
} from "$lib/forum/partials";
// Named slots a partial can fill. The NIP-23 `d` tag carries the slot name. export type { Partial, PartialSlot };
export type PartialSlot = "home" | "contacts";
const SLOTS = new Set<string>(["home", "contacts"]);
export type Partial = {
id: string;
slot: PartialSlot;
title: string;
content: string; // markdown body
pubkey: string;
createdAt: number;
};
let all = $state<Partial[]>([]); let all = $state<Partial[]>([]);
let loaded = $state(false); let loaded = $state(false);
export const partialsStore = { export const partialsStore = {
// The newest admin-authored partial for a slot, or undefined. The relay query
// is open, so the trusted admin set is the gate; resolving the winner here
// (rather than at load time) keeps a non-admin from shadowing it with a newer
// event, and picks up the admin set once it finishes loading in full mode.
get(slot: PartialSlot): Partial | undefined { get(slot: PartialSlot): Partial | undefined {
const admins = new Set(adminPubkeys.list); return pickPartial(all, slot, adminPubkeys.list);
let best: Partial | undefined;
for (const p of all) {
if (p.slot !== slot || !admins.has(p.pubkey)) continue;
if (!best || p.createdAt > best.createdAt) best = p;
}
return best;
}, },
get loaded() { get loaded() {
return loaded; return loaded;
}, },
}; };
// 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() { export async function loadPartials() {
try { try {
const events = await queryForum({ all = await fetchPartials(queryForum);
kinds: [30023],
"#t": ["squalk-partial"],
});
const next: Partial[] = [];
for (const e of events) {
const slot = e.tags.find((t) => t[0] === "d")?.[1];
if (!slot || !SLOTS.has(slot)) continue;
next.push({
id: e.id,
slot: slot as PartialSlot,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slot,
content: e.content,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
all = next;
} finally { } finally {
loaded = true; loaded = true;
} }

View file

@ -1,22 +1,15 @@
import { adminPubkeys } from "$lib/admins.svelte"; import { adminPubkeys } from "$lib/admins.svelte";
import { queryForum } from "$lib/relay"; import { queryForum } from "$lib/relay";
import { fetchResources, type Resource } from "$lib/forum/resources";
export type Resource = { export type { Resource };
id: string;
slug: string; // NIP-23 `d` tag — also the resource URL slug
title: string;
content: string; // markdown body
position?: number;
pubkey: string;
createdAt: number;
};
let all = $state<Resource[]>([]); let all = $state<Resource[]>([]);
let loaded = $state(false); let loaded = $state(false);
// Only admin-authored resources are surfaced: the relay query is open, so the
// admin set is the trust gate.
export const resourcesStore = { export const resourcesStore = {
// Only resources authored by an admin are surfaced; the relay query is
// open, so the trusted admin set is the gate.
get list() { get list() {
const admins = new Set(adminPubkeys.list); const admins = new Set(adminPubkeys.list);
return all.filter((r) => admins.has(r.pubkey)); return all.filter((r) => admins.has(r.pubkey));
@ -26,45 +19,9 @@ export const resourcesStore = {
}, },
}; };
// Positioned resources come first by ascending `position`; the rest follow
// alphabetically by title. Equal positions fall back to title order.
function compare(a: Resource, b: Resource): number {
const ap = a.position;
const bp = b.position;
if (ap !== undefined && bp !== undefined)
return ap !== bp ? ap - bp : a.title.localeCompare(b.title);
if (ap !== undefined) return -1;
if (bp !== undefined) return 1;
return a.title.localeCompare(b.title);
}
// Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"].
export async function loadResources() { export async function loadResources() {
try { try {
const events = await queryForum({ all = await fetchResources(queryForum);
kinds: [30023],
"#t": ["squalk-resource"],
});
// Addressable events: keep the newest per `d` slug.
const bySlug = new Map<string, Resource>();
for (const e of events) {
const slug = e.tags.find((t) => t[0] === "d")?.[1];
if (!slug) continue;
const existing = bySlug.get(slug);
if (existing && existing.createdAt >= e.created_at) continue;
const posTag = e.tags.find((t) => t[0] === "position")?.[1];
const pos = posTag !== undefined ? Number(posTag) : NaN;
bySlug.set(slug, {
id: e.id,
slug,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? slug,
content: e.content,
position: Number.isFinite(pos) ? pos : undefined,
pubkey: e.pubkey,
createdAt: e.created_at,
});
}
all = [...bySlug.values()].sort(compare);
} finally { } finally {
loaded = true; loaded = true;
} }

View file

@ -10,25 +10,16 @@ import {
buildPTagHints, buildPTagHints,
} from "$lib/mentions"; } from "$lib/mentions";
import { convertForumUrls } from "$lib/linkify"; import { convertForumUrls } from "$lib/linkify";
import {
fetchThread,
type PostData,
type ThreadDetail,
} from "$lib/forum/thread";
export type { PostData };
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id); const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
export type PostData = {
id: string;
pubkey: string;
createdAt: number;
content: string;
};
type ThreadDetail = {
id: string;
title: string;
labels: string[];
groupId: string; // the thread's NIP-29 group (its `h` tag)
op: PostData;
replies: PostData[];
};
let detail = $state<ThreadDetail | null>(null); let detail = $state<ThreadDetail | null>(null);
let profiles = $state<Record<string, NostrUser>>({}); let profiles = $state<Record<string, NostrUser>>({});
// "notfound" means the relay returned nothing — either no such thread or it sits // "notfound" means the relay returned nothing — either no such thread or it sits
@ -104,39 +95,13 @@ export async function loadThread(id: string) {
return; return;
} }
const [threadEvents, replyEvents] = await Promise.all([ const next = await fetchThread(queryForum, id, GROUP_ID);
queryForum({ kinds: [11], ids: [id] }), if (!next) {
queryForum({ kinds: [1111], "#E": [id] }),
]);
const event = threadEvents[0];
if (!event) {
status = "notfound"; status = "notfound";
return; return;
} }
detail = next;
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at); [next.op.pubkey, ...next.replies.map((r) => r.pubkey)].forEach(loadProfile);
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,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
},
replies: replies.map((r) => ({
id: r.id,
pubkey: r.pubkey,
createdAt: r.created_at,
content: r.content,
})),
};
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
status = "ready"; status = "ready";
} }

View file

@ -1,27 +1,14 @@
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 "$lib/gadgets"; import { loadNostrUser, type NostrUser } from "$lib/gadgets";
import { ensureForumRelay } from "$lib/relay"; import { ensureForumRelay } from "$lib/relay";
import { ingestNostrUser } from "$lib/profiles.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte";
import { relayQuery } from "$lib/forum/query";
import {
fetchThreadPage,
type SortMode,
type ThreadData,
} from "$lib/forum/threads";
const PAGE_SIZE = 30; export type { SortMode, ThreadData };
const WALK_LIMIT = 150; // Events per walk query (~5x PAGE_SIZE)
const WALK_MAX_ITERS = 12;
export type SortMode = "active" | "new";
export type ThreadData = {
id: string;
title: string;
labels: string[];
authorPubkey: string;
createdAt: number;
replyCount: number;
latestAt: number;
latestPubkey: string;
replierPubkeys: string[]; // unique reply authors, excl. OP, max 4
};
let threads = $state<ThreadData[]>([]); let threads = $state<ThreadData[]>([]);
let profiles = $state<Record<string, NostrUser>>({}); let profiles = $state<Record<string, NostrUser>>({});
@ -59,238 +46,29 @@ async function loadProfile(pubkey: string) {
ingestNostrUser(user); ingestNostrUser(user);
} }
function querySync(relay: AbstractRelay, filter: Filter): Promise<Event[]> {
return new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
function threadIdOf(e: Event): string | undefined {
return e.kind === 11 ? e.id : e.tags.find((t) => t[0] === "E")?.[1];
}
type SliceItem = {
id: string;
latestAt: number;
latestPubkey: string;
op?: Event;
};
// Walk the combined OP + reply stream newest-first, collecting up to `n` unique
// 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: AbstractRelay,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
const collected = new Map<string, SliceItem>();
let cur = until;
let done = false;
for (let i = 0; i < WALK_MAX_ITERS && collected.size < n; i++) {
const events = await querySync(relay, {
kinds: [11, 1111],
"#h": [groupId],
until: cur,
limit: WALK_LIMIT,
});
if (events.length === 0) {
done = true;
break;
}
events.sort((a, b) => b.created_at - a.created_at);
let oldest = cur;
for (const e of events) {
oldest = Math.min(oldest, e.created_at);
const id = threadIdOf(e);
if (!id || exclude.has(id)) continue;
const seen = collected.get(id);
if (seen) {
// Grab the OP from the stream when a thread was first seen via a reply,
// so we avoid the by-id backfill the group relay truncates
if (!seen.op && e.kind === 11) seen.op = e;
continue;
}
if (collected.size >= n) continue; // Page full; keep scanning for OPs
collected.set(id, {
id,
latestAt: e.created_at,
latestPubkey: e.pubkey,
op: e.kind === 11 ? e : undefined,
});
}
if (events.length < WALK_LIMIT) {
done = true;
break;
}
if (oldest >= cur) break; // No progress (single timestamp floods the window)
cur = oldest; // Inclusive; thread-level dedupe absorbs re-reads
}
const items = [...collected.values()].sort((a, b) => b.latestAt - a.latestAt);
const nextCursor = items.length > 0 ? items[items.length - 1].latestAt : null;
return { items, nextCursor, done };
}
// Chronological-by-creation slice: just OPs ordered by created_at. No reply
// data is needed to order them (stable cursor), keeping the path cheap; reply
// counts are still attached later via enrichment.
async function fetchNewSlice(
relay: AbstractRelay,
groupId: string,
until: number,
exclude: Set<string>,
n: number,
): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> {
const ops = await querySync(relay, {
kinds: [11],
"#h": [groupId],
until,
limit: n + 10, // headroom for boundary OPs re-read at the inclusive cursor
});
ops.sort((a, b) => b.created_at - a.created_at);
const fresh = ops.filter((e) => !exclude.has(e.id));
const slice = fresh.slice(0, n);
const items: SliceItem[] = slice.map((op) => ({
id: op.id,
latestAt: op.created_at,
latestPubkey: op.pubkey,
op,
}));
const done = ops.length < n + 10;
const last = slice[slice.length - 1] ?? ops[ops.length - 1];
const nextCursor = last ? last.created_at : null;
return { items, nextCursor, done };
}
// 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: AbstractRelay,
groupId: string,
items: SliceItem[],
): Promise<Map<string, { count: number; repliers: string[] }>> {
const result = new Map<string, { count: number; repliers: string[] }>();
if (items.length === 0) return result;
const replies = await querySync(relay, {
kinds: [1111],
"#h": [groupId],
"#E": items.map((it) => it.id),
until: snapshotAt,
limit: 5000,
});
const acc = new Map<string, { count: number; pubkeys: Set<string> }>();
for (const r of replies) {
const root = r.tags.find((t) => t[0] === "E")?.[1];
if (!root) continue;
let a = acc.get(root);
if (!a) {
a = { count: 0, pubkeys: new Set() };
acc.set(root, a);
}
a.count++;
a.pubkeys.add(r.pubkey);
}
for (const it of items) {
const a = acc.get(it.id);
const repliers = a
? [...a.pubkeys].filter((p) => p !== it.op?.pubkey).slice(0, 4)
: [];
result.set(it.id, { count: a?.count ?? 0, repliers });
}
return result;
}
async function buildThreads(
relay: AbstractRelay,
groupId: string,
items: SliceItem[],
): Promise<ThreadData[]> {
// Backfill OPs that fell outside the activity window. The group relay
// truncates multi-id queries, so fetch each one on its own.
const missing = items.filter((it) => !it.op);
if (missing.length > 0) {
const fetched = await Promise.all(
missing.map((it) =>
querySync(relay, { kinds: [11], "#h": [groupId], ids: [it.id] }),
),
);
const byId = new Map<string, Event>();
for (const evs of fetched) for (const e of evs) byId.set(e.id, e);
for (const it of items) if (!it.op) it.op = byId.get(it.id);
}
const enriched = await enrichWithReplies(relay, groupId, items);
const out: ThreadData[] = [];
for (const it of items) {
const op = it.op;
if (!op) continue; // OP missing (deleted/unavailable) — drop the row
const e = enriched.get(it.id);
out.push({
id: it.id,
title: op.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
labels: op.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
authorPubkey: op.pubkey,
createdAt: op.created_at,
replyCount: e?.count ?? 0,
latestAt: it.latestAt,
latestPubkey: it.latestPubkey,
replierPubkeys: e?.repliers ?? [],
});
}
return out;
}
async function runLoad(append: boolean, groupId: string) { async function runLoad(append: boolean, groupId: string) {
const id = ++reqId; const id = ++reqId;
if (append) loadingMore = true; if (append) loadingMore = true;
else loading = true; else loading = true;
// Paged loads drive their own subscriptions on the shared connection
const relay = await ensureForumRelay(); const q = relayQuery(await ensureForumRelay());
try { try {
const until = append ? (cursor ?? snapshotAt) : snapshotAt; const page = await fetchThreadPage(q, groupId, {
const exclude = new Set(threads.map((t) => t.id)); sort: sortMode,
const slice = until: append ? (cursor ?? snapshotAt) : snapshotAt,
sortMode === "new" snapshotAt,
? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE) exclude: new Set(threads.map((t) => t.id)),
: await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE); });
const built = await buildThreads(relay, groupId, slice.items);
if (id !== reqId) return; // Superseded by a newer load — discard results if (id !== reqId) return; // Superseded by a newer load — discard results
threads = append ? [...threads, ...page.threads] : page.threads;
threads = append ? [...threads, ...built] : built; cursor = page.nextCursor ?? cursor;
cursor = slice.nextCursor ?? cursor; exhausted = page.done || page.threads.length === 0;
exhausted = slice.done || built.length === 0; for (const t of page.threads) {
for (const t of built) {
loadProfile(t.authorPubkey); loadProfile(t.authorPubkey);
loadProfile(t.latestPubkey); loadProfile(t.latestPubkey);
for (const p of t.replierPubkeys) loadProfile(p); for (const p of t.replierPubkeys) loadProfile(p);
} }
} finally { } finally {
// 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;