Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 139 lines 4.1 kB view raw
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}