Extract relay-agnostic forum fetchers from the stores
This commit is contained in:
parent
54c0682dce
commit
a7b5d8f223
16 changed files with 694 additions and 531 deletions
|
|
@ -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<Record<string, string[]>>({});
|
||||
|
|
@ -48,17 +49,7 @@ export async function loadRoomAdmins(roomIds: string[]) {
|
|||
loadedKey = key;
|
||||
|
||||
try {
|
||||
const events = await queryForum({
|
||||
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;
|
||||
byRoom = await fetchRoomAdmins(queryForum, roomIds);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
|
|
|
|||
81
src/lib/forum/groups.ts
Normal file
81
src/lib/forum/groups.ts
Normal 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
73
src/lib/forum/overview.ts
Normal 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
48
src/lib/forum/partials.ts
Normal 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
49
src/lib/forum/profiles.ts
Normal 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
36
src/lib/forum/query.ts
Normal 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]);
|
||||
}
|
||||
47
src/lib/forum/resources.ts
Normal file
47
src/lib/forum/resources.ts
Normal 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
56
src/lib/forum/thread.ts
Normal 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
238
src/lib/forum/threads.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<GroupSummary[]>([]);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, RoomActivity>>({});
|
||||
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) {
|
||||
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<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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>(["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<Partial[]>([]);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Resource[]>([]);
|
||||
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<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);
|
||||
all = await fetchResources(queryForum);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ThreadDetail | null>(null);
|
||||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
// "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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ThreadData[]>([]);
|
||||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
|
|
@ -59,238 +46,29 @@ async function loadProfile(pubkey: string) {
|
|||
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) {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue