diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts index 7345f65..9848cc5 100644 --- a/src/lib/admins.svelte.ts +++ b/src/lib/admins.svelte.ts @@ -1,6 +1,7 @@ import { MODE } from "$lib/config"; import { groupStore } from "$lib/group.svelte"; import { queryForum } from "$lib/relay"; +import { fetchRoomAdmins } from "$lib/forum/groups"; // room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms. let byRoom = $state>({}); @@ -48,17 +49,7 @@ export async function loadRoomAdmins(roomIds: string[]) { loadedKey = key; try { - const events = await queryForum({ - kinds: [39001], - "#d": roomIds, - }); - const map: Record = {}; - 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; + byRoom = await fetchRoomAdmins(queryForum, roomIds); } finally { loaded = true; } diff --git a/src/lib/forum/groups.ts b/src/lib/forum/groups.ts new file mode 100644 index 0000000..846f50c --- /dev/null +++ b/src/lib/forum/groups.ts @@ -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 { + 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 { + 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> { + if (roomIds.length === 0) return {}; + const events = await q({ kinds: [39001], "#d": roomIds }); + const map: Record = {}; + for (const e of events) { + const d = tag(e, "d"); + if (d) map[d] = tags(e, "p"); + } + return map; +} diff --git a/src/lib/forum/overview.ts b/src/lib/forum/overview.ts new file mode 100644 index 0000000..ad750b0 --- /dev/null +++ b/src/lib/forum/overview.ts @@ -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; + admins: Record; // 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 { + const latest = await Promise.all( + roomIds.map((id) => q({ kinds: [11, 1111], "#h": [id], limit: 1 })), + ); + const activity: Record = {}; + 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 = {}; + 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), + ]), + ]; +} diff --git a/src/lib/forum/partials.ts b/src/lib/forum/partials.ts new file mode 100644 index 0000000..60c37ab --- /dev/null +++ b/src/lib/forum/partials.ts @@ -0,0 +1,48 @@ +import { tag, type Query } from "./query"; + +export type PartialSlot = "home" | "contacts"; +const SLOTS = new Set(["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 { + 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; +} diff --git a/src/lib/forum/profiles.ts b/src/lib/forum/profiles.ts new file mode 100644 index 0000000..29208aa --- /dev/null +++ b/src/lib/forum/profiles.ts @@ -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> { + const out: Record = {}; + 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; +} diff --git a/src/lib/forum/query.ts b/src/lib/forum/query.ts new file mode 100644 index 0000000..911715e --- /dev/null +++ b/src/lib/forum/query.ts @@ -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; + +// 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]); +} diff --git a/src/lib/forum/resources.ts b/src/lib/forum/resources.ts new file mode 100644 index 0000000..e6c0181 --- /dev/null +++ b/src/lib/forum/resources.ts @@ -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 { + const events = await q({ kinds: [30023], "#t": ["squalk-resource"] }); + const bySlug = new Map(); + 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); +} diff --git a/src/lib/forum/thread.ts b/src/lib/forum/thread.ts new file mode 100644 index 0000000..3ffd263 --- /dev/null +++ b/src/lib/forum/thread.ts @@ -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 { + 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)])]; +} diff --git a/src/lib/forum/threads.ts b/src/lib/forum/threads.ts new file mode 100644 index 0000000..c80bb86 --- /dev/null +++ b/src/lib/forum/threads.ts @@ -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; // 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, + n: number, +): Promise { + const collected = new Map(); + 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, + n: number, +): Promise { + 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> { + const result = new Map(); + 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 }>(); + 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 { + 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(); + 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 { + const exclude = req.exclude ?? new Set(); + 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(); + for (const t of threads) { + set.add(t.authorPubkey); + set.add(t.latestPubkey); + for (const p of t.replierPubkeys) set.add(p); + } + return [...set]; +} diff --git a/src/lib/group.svelte.ts b/src/lib/group.svelte.ts index 289e50f..c2612a1 100644 --- a/src/lib/group.svelte.ts +++ b/src/lib/group.svelte.ts @@ -1,20 +1,10 @@ import { GROUP_ID, MODE } from "$lib/config"; import { queryForum } from "$lib/relay"; import { groupsStore } from "$lib/groups.svelte"; +import { fetchGroup, type GroupMetadata } from "$lib/forum/groups"; -export type GroupMetadata = { - name: string; - picture?: string; - about?: string; - isPrivate: boolean; - isClosed: boolean; - isRestricted: boolean; - isHidden: boolean; - admins: string[]; -}; +export type { GroupMetadata }; -// 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 = { isPrivate: boolean; isClosed: boolean; @@ -36,33 +26,12 @@ export const groupStore = { export async function loadGroup() { try { - const events = await queryForum({ - 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, - }; + group = (await fetchGroup(queryForum, GROUP_ID)) ?? group; } finally { 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 { if (MODE === "full") { const g = groupsStore.list.find((x) => x.id === groupId); diff --git a/src/lib/groups.svelte.ts b/src/lib/groups.svelte.ts index c881b24..25173d9 100644 --- a/src/lib/groups.svelte.ts +++ b/src/lib/groups.svelte.ts @@ -1,16 +1,7 @@ import { queryForum } from "$lib/relay"; +import { fetchGroups, type GroupSummary } from "$lib/forum/groups"; -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"]; +export type { GroupSummary }; let list = $state([]); 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() { try { - const events = await queryForum({ kinds: [39000] }); - 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); + list = await fetchGroups(queryForum); } finally { loaded = true; } diff --git a/src/lib/overview.svelte.ts b/src/lib/overview.svelte.ts index 79a8550..0dcfeac 100644 --- a/src/lib/overview.svelte.ts +++ b/src/lib/overview.svelte.ts @@ -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 { ensureForumRelay } from "$lib/relay"; 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 = { - latestAt: number; - latestPubkey: string; -}; - -export type RecentThread = { - id: string; - title: string; - groupId: string; - authorPubkey: string; - createdAt: number; -}; +export type { RecentThread, RoomActivity }; let activity = $state>({}); let admins = $state>({}); // room id -> first admin pubkey @@ -43,24 +36,6 @@ export const overviewStore = { }, }; -function querySync(relay: AbstractRelay, filter: Filter): Promise { - 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) { if (profiles[pubkey]) return; const user = await loadNostrUser(pubkey); @@ -77,54 +52,14 @@ export async function loadOverview(roomIds: string[]) { loadedKey = key; loading = true; - const relay = await ensureForumRelay(); try { - // One tiny query per room for its newest event (thread or reply). - const latest = await Promise.all( - roomIds.map((id) => - querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }), - ), - ); - const act: Record = {}; - 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 = {}; - 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); + const q = relayQuery(await ensureForumRelay()); + const o = await fetchOverview(q, roomIds); + activity = o.activity; + admins = o.admins; + recent = o.recent; + overviewPubkeys(o).forEach(loadProfile); } finally { - // shared forum connection is long-lived — don't close it here loading = false; } } diff --git a/src/lib/partials.svelte.ts b/src/lib/partials.svelte.ts index ba45c49..9545d7c 100644 --- a/src/lib/partials.svelte.ts +++ b/src/lib/partials.svelte.ts @@ -1,63 +1,29 @@ import { adminPubkeys } from "$lib/admins.svelte"; 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 PartialSlot = "home" | "contacts"; -const SLOTS = new Set(["home", "contacts"]); - -export type Partial = { - id: string; - slot: PartialSlot; - title: string; - content: string; // markdown body - pubkey: string; - createdAt: number; -}; +export type { Partial, PartialSlot }; let all = $state([]); let loaded = $state(false); 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 { - const admins = new Set(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; + return pickPartial(all, slot, adminPubkeys.list); }, get 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() { try { - const events = await 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; + all = await fetchPartials(queryForum); } finally { loaded = true; } diff --git a/src/lib/resources.svelte.ts b/src/lib/resources.svelte.ts index 7d4b321..2e29e26 100644 --- a/src/lib/resources.svelte.ts +++ b/src/lib/resources.svelte.ts @@ -1,22 +1,15 @@ import { adminPubkeys } from "$lib/admins.svelte"; import { queryForum } from "$lib/relay"; +import { fetchResources, type Resource } from "$lib/forum/resources"; -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; -}; +export type { Resource }; let all = $state([]); 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 = { - // Only resources authored by an admin are surfaced; the relay query is - // open, so the trusted admin set is the gate. get list() { const admins = new Set(adminPubkeys.list); 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() { try { - const events = await queryForum({ - kinds: [30023], - "#t": ["squalk-resource"], - }); - // Addressable events: keep the newest per `d` slug. - const bySlug = new Map(); - 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); + all = await fetchResources(queryForum); } finally { loaded = true; } diff --git a/src/lib/thread.svelte.ts b/src/lib/thread.svelte.ts index d91a382..66623a7 100644 --- a/src/lib/thread.svelte.ts +++ b/src/lib/thread.svelte.ts @@ -10,25 +10,16 @@ import { buildPTagHints, } from "$lib/mentions"; 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); -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(null); let profiles = $state>({}); // "notfound" means the relay returned nothing — either no such thread or it sits @@ -104,39 +95,13 @@ export async function loadThread(id: string) { return; } - const [threadEvents, replyEvents] = await Promise.all([ - queryForum({ kinds: [11], ids: [id] }), - queryForum({ kinds: [1111], "#E": [id] }), - ]); - - const event = threadEvents[0]; - if (!event) { + const next = await fetchThread(queryForum, id, GROUP_ID); + if (!next) { status = "notfound"; return; } - - const replies = replyEvents.sort((a, b) => a.created_at - b.created_at); - - 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); + detail = next; + [next.op.pubkey, ...next.replies.map((r) => r.pubkey)].forEach(loadProfile); status = "ready"; } diff --git a/src/lib/threads.svelte.ts b/src/lib/threads.svelte.ts index ff31a9a..1ce3c1e 100644 --- a/src/lib/threads.svelte.ts +++ b/src/lib/threads.svelte.ts @@ -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 { ensureForumRelay } from "$lib/relay"; 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; -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 { SortMode, ThreadData }; let threads = $state([]); let profiles = $state>({}); @@ -59,238 +46,29 @@ async function loadProfile(pubkey: string) { ingestNostrUser(user); } -function querySync(relay: AbstractRelay, filter: Filter): Promise { - 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, - n: number, -): Promise<{ items: SliceItem[]; nextCursor: number | null; done: boolean }> { - const collected = new Map(); - 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, - 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> { - const result = new Map(); - 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 }>(); - 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 { - // 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(); - 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) { const id = ++reqId; if (append) loadingMore = true; else loading = true; - - const relay = await ensureForumRelay(); + // Paged loads drive their own subscriptions on the shared connection + const q = relayQuery(await ensureForumRelay()); try { - const until = append ? (cursor ?? snapshotAt) : snapshotAt; - const exclude = new Set(threads.map((t) => t.id)); - const slice = - sortMode === "new" - ? await fetchNewSlice(relay, groupId, until, exclude, PAGE_SIZE) - : await fetchActivitySlice(relay, groupId, until, exclude, PAGE_SIZE); - const built = await buildThreads(relay, groupId, slice.items); - + const page = await fetchThreadPage(q, groupId, { + sort: sortMode, + until: append ? (cursor ?? snapshotAt) : snapshotAt, + snapshotAt, + exclude: new Set(threads.map((t) => t.id)), + }); if (id !== reqId) return; // Superseded by a newer load — discard results - - threads = append ? [...threads, ...built] : built; - cursor = slice.nextCursor ?? cursor; - exhausted = slice.done || built.length === 0; - - for (const t of built) { + threads = append ? [...threads, ...page.threads] : page.threads; + cursor = page.nextCursor ?? cursor; + exhausted = page.done || page.threads.length === 0; + for (const t of page.threads) { loadProfile(t.authorPubkey); loadProfile(t.latestPubkey); for (const p of t.replierPubkeys) loadProfile(p); } } finally { - // shared forum connection is long-lived — don't close it here if (id === reqId) { if (append) loadingMore = false; else loading = false;