Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
1/** Thread detail fetchers: root post, reply refs, and the hydrated
2 * reply records for one page of the thread. */
3
4import {
5 getBacklinks,
6 getRecord,
7 getRecordsBatch,
8 resolveIdentitiesBatch,
9 resolveIdentity,
10 type BacklinkRef,
11} from "./atproto";
12import { POST } from "./lexicon";
13import { makeAtUri, parseAtUri } from "./util";
14import { recordToReply } from "./replies";
15import { isPostRecord } from "./recordGuards";
16import type { Reply } from "../components/post/ReplyCard";
17
18export interface ThreadRoot {
19 uri: string;
20 did: string;
21 rkey: string;
22 authorHandle: string;
23 authorPds: string;
24 title: string;
25 body: string;
26 createdAt: string;
27 boardSlug: string;
28 attachments?: { file: { ref: { $link: string } }; name: string }[];
29}
30
31const MAX_REF_PAGES = 20;
32const REF_PAGE_SIZE = 100;
33
34/** Every reply ref for the thread, oldest-first. */
35export async function fetchThreadRefs(
36 threadUri: string,
37): Promise<BacklinkRef[]> {
38 const collected: BacklinkRef[] = [];
39 let cursor: string | undefined;
40 for (let i = 0; i < MAX_REF_PAGES; i++) {
41 const page = await getBacklinks(
42 threadUri,
43 `${POST}:root`,
44 REF_PAGE_SIZE,
45 cursor,
46 );
47 collected.push(...page.records);
48 if (!page.cursor) break;
49 cursor = page.cursor;
50 }
51 return collected.reverse();
52}
53
54export async function fetchThreadRoot(
55 did: string,
56 tid: string,
57): Promise<ThreadRoot> {
58 const threadRecord = await getRecord(did, POST, tid);
59 if (!isPostRecord(threadRecord)) {
60 throw new Error("Invalid post record");
61 }
62 const author = await resolveIdentity(did);
63 const postValue = threadRecord.value;
64 const boardSlug = parseAtUri(postValue.scope).rkey;
65 return {
66 uri: threadRecord.uri,
67 did,
68 rkey: tid,
69 authorHandle: author.handle,
70 authorPds: author.pds ?? "",
71 title: postValue.title ?? "",
72 body: postValue.body,
73 createdAt: postValue.createdAt,
74 boardSlug,
75 attachments: postValue.attachments as ThreadRoot["attachments"],
76 };
77}
78
79export function threadUriFor(did: string, tid: string): string {
80 return makeAtUri(did, POST, tid);
81}
82
83export interface ReplyPage {
84 replies: Reply[];
85 /** Lookup by URI for any reply referenced as a parent — includes both
86 * on-page replies and off-page parents fetched separately. */
87 parentReplies: Record<string, Reply>;
88}
89
90export async function hydrateReplyPage(
91 pageRefs: BacklinkRef[],
92): Promise<ReplyPage> {
93 if (!pageRefs.length) return { replies: [], parentReplies: {} };
94
95 const records = await getRecordsBatch(pageRefs);
96 const authors = await resolveIdentitiesBatch(
97 records.map((r) => parseAtUri(r.uri).did),
98 );
99 const replies: Reply[] = records
100 .map((record) => recordToReply(record, authors))
101 .filter((reply): reply is Reply => reply !== null)
102 .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
103
104 const parentReplies: Record<string, Reply> = {};
105 for (const reply of replies) parentReplies[reply.uri] = reply;
106
107 const offPageParentUris = [
108 ...new Set(
109 replies
110 .map((r) => r.parent)
111 .filter((uri): uri is string => !!uri && !parentReplies[uri]),
112 ),
113 ];
114 if (offPageParentUris.length) {
115 const parentRefs = offPageParentUris.map((uri) => parseAtUri(uri));
116 const parentRecords = await getRecordsBatch(parentRefs);
117 const parentAuthors = await resolveIdentitiesBatch(
118 parentRecords.map((r) => parseAtUri(r.uri).did),
119 );
120 for (const record of parentRecords) {
121 const reply = recordToReply(record, parentAuthors);
122 if (reply) parentReplies[reply.uri] = reply;
123 }
124 }
125
126 return { replies, parentReplies };
127}