Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
1/** Activity data — replies to your posts from other users. */
2
3import { fetchAndHydrate, listRecords, resolveIdentitiesBatch } from "./atproto";
4import { POST } from "./lexicon";
5import { isPostRecord } from "./recordGuards";
6import { parseAtUri } from "./util";
7
8export interface ActivityItem {
9 type: "reply" | "parent_reply";
10 threadTitle: string;
11 threadUri: string;
12 bbsHandle: string;
13 replyUri: string;
14 handle: string;
15 body: string;
16 createdAt: string;
17}
18
19async function fetchBacklinkItems(
20 sourceUri: string,
21 backlinkSource: string,
22 excludeDid: string,
23 type: ActivityItem["type"],
24 threadTitle: string,
25 threadUri: string,
26 bbsHandle: string,
27): Promise<ActivityItem[]> {
28 try {
29 const { records } = await fetchAndHydrate(sourceUri, backlinkSource, {
30 limit: 50,
31 excludeDid,
32 });
33 return records.map((record) => ({
34 type,
35 threadTitle,
36 threadUri,
37 bbsHandle,
38 replyUri: record.uri,
39 handle: record.handle,
40 body: ((record.value.body as string) ?? "").substring(0, 200),
41 createdAt: (record.value.createdAt as string) ?? "",
42 }));
43 } catch {
44 return [];
45 }
46}
47
48export async function fetchActivity(
49 did: string,
50 pdsUrl: string,
51): Promise<ActivityItem[]> {
52 const SCAN_LIMIT = 50;
53 const allPosts = await listRecords(pdsUrl, did, POST, SCAN_LIMIT);
54 const validPosts = allPosts.filter(isPostRecord);
55
56 const rootPosts = validPosts.filter((record) => !record.value.root);
57 const replyPosts = validPosts.filter((record) => !!record.value.root);
58
59 const bbsDids = new Set(
60 validPosts.map((record) => parseAtUri(record.value.scope).did),
61 );
62 const bbsIdentities = await resolveIdentitiesBatch([...bbsDids]);
63
64 const results = await Promise.all([
65 ...rootPosts.map((post) => {
66 const bbsDid = parseAtUri(post.value.scope).did;
67 const bbsHandle = bbsIdentities[bbsDid]?.handle;
68 if (!bbsHandle) return Promise.resolve([] as ActivityItem[]);
69 return fetchBacklinkItems(
70 post.uri,
71 `${POST}:root`,
72 did,
73 "reply",
74 post.value.title ?? "",
75 post.uri,
76 bbsHandle,
77 );
78 }),
79 ...replyPosts.map((reply) => {
80 const bbsDid = parseAtUri(reply.value.scope).did;
81 const bbsHandle = bbsIdentities[bbsDid]?.handle;
82 if (!bbsHandle) return Promise.resolve([] as ActivityItem[]);
83 return fetchBacklinkItems(
84 reply.uri,
85 `${POST}:parent`,
86 did,
87 "parent_reply",
88 "",
89 reply.value.root ?? "",
90 bbsHandle,
91 );
92 }),
93 ]);
94
95 // Deduplicate — prefer "parent-reply" type when the same reply appears as both.
96 const seen = new Map<string, ActivityItem>();
97 for (const item of results.flat()) {
98 const key = item.handle + item.body + item.createdAt;
99 if (!seen.has(key) || item.type === "parent_reply") seen.set(key, item);
100 }
101 return [...seen.values()].sort((a, b) =>
102 b.createdAt.localeCompare(a.createdAt),
103 );
104}