Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
1/** Build a page of thread summaries for a board, sorted by last activity.
2 *
3 * Scans recent board activity (threads + replies) from Constellation and
4 * collects unique thread URIs in the order they appear. Since Constellation
5 * returns newest posts first, the first time a thread URI appears is its
6 * most recent activity — giving us bump order naturally. */
7
8import {
9 getAvatars,
10 getBacklinkCountsBatch,
11 getBacklinks,
12 getRecordsBatch,
13 getRecordsByUri,
14 resolveIdentitiesBatch,
15} from "./atproto";
16import { POST, BOARD } from "./lexicon";
17import { makeAtUri, parseAtUri } from "./util";
18import { isPostRecord } from "./recordGuards";
19
20export interface Participant {
21 did: string;
22 handle: string;
23 avatar?: string;
24}
25
26export interface ThreadItem {
27 uri: string;
28 did: string;
29 rkey: string;
30 handle: string;
31 title: string;
32 body: string;
33 createdAt: string;
34 lastActivityAt: string;
35 replyCount: number;
36 participants: Participant[];
37}
38
39export interface ThreadPageResult {
40 threads: ThreadItem[];
41 cursor: string | null;
42}
43
44const MAX_SCANS = 4;
45const PAGE_SIZE = 25;
46
47export async function hydrateThreadPage(
48 bbsDid: string,
49 slug: string,
50 cursor?: string,
51): Promise<ThreadPageResult> {
52 const boardUri = makeAtUri(bbsDid, BOARD, slug);
53
54 const lastActivity = new Map<string, string>();
55 const postersByThread = new Map<string, Set<string>>();
56 let scanCursor = cursor;
57
58 for (let scan = 0; scan < MAX_SCANS; scan++) {
59 if (lastActivity.size >= PAGE_SIZE) break;
60
61 const backlinks = await getBacklinks(
62 boardUri,
63 `${POST}:scope`,
64 100,
65 scanCursor,
66 );
67 if (!backlinks.records.length) break;
68
69 const records = await getRecordsBatch(backlinks.records);
70 for (const record of records) {
71 if (!isPostRecord(record)) continue;
72 const threadUri = record.value.root ?? record.uri;
73 if (!lastActivity.has(threadUri)) {
74 lastActivity.set(threadUri, record.value.createdAt);
75 }
76 let posters = postersByThread.get(threadUri);
77 if (!posters) {
78 posters = new Set();
79 postersByThread.set(threadUri, posters);
80 }
81 posters.add(parseAtUri(record.uri).did);
82 }
83
84 scanCursor = backlinks.cursor;
85 if (!scanCursor) break;
86 }
87
88 const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE);
89 const rootRecords = await getRecordsByUri(threadUris);
90
91 const validRoots = rootRecords
92 .filter(isPostRecord)
93 .filter((record) => record.value.title && !record.value.root);
94
95 const allDids = new Set<string>();
96 for (const record of validRoots) {
97 allDids.add(parseAtUri(record.uri).did);
98 const posters = postersByThread.get(record.uri);
99 if (posters) for (const did of posters) allDids.add(did);
100 }
101
102 const [identities, replyCounts, avatars] = await Promise.all([
103 resolveIdentitiesBatch([...allDids]),
104 getBacklinkCountsBatch(
105 validRoots.map((record) => record.uri),
106 `${POST}:root`,
107 ),
108 getAvatars([...allDids]),
109 ]);
110
111 const threads: ThreadItem[] = validRoots
112 .filter((record) => parseAtUri(record.uri).did in identities)
113 .map((record) => {
114 const { did, rkey } = parseAtUri(record.uri);
115 const posterDids = postersByThread.get(record.uri) ?? new Set([did]);
116 const participants: Participant[] = [...posterDids]
117 .filter((posterDid) => posterDid in identities)
118 .map((posterDid) => ({
119 did: posterDid,
120 handle: identities[posterDid].handle,
121 avatar: avatars[posterDid],
122 }));
123 return {
124 uri: record.uri,
125 did,
126 rkey,
127 handle: identities[did].handle,
128 title: record.value.title ?? "",
129 body: record.value.body,
130 createdAt: record.value.createdAt,
131 lastActivityAt: lastActivity.get(record.uri) ?? record.value.createdAt,
132 replyCount: replyCounts[record.uri] ?? 0,
133 participants,
134 };
135 })
136 .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
137
138 return { threads, cursor: scanCursor ?? null };
139}