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