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.

add bump order for threads

+160 -31
+16
core/constellation.py
··· 37 37 ) 38 38 39 39 40 + async def get_board_activity( 41 + client: httpx.AsyncClient, 42 + board_uri: str, 43 + limit: int = 100, 44 + cursor: str | None = None, 45 + ) -> BacklinksResponse: 46 + """Get all posts (threads and replies) for a board, newest first.""" 47 + return await get_backlinks( 48 + client, 49 + subject=board_uri, 50 + source=f"{lexicon.POST}:scope", 51 + limit=limit, 52 + cursor=cursor, 53 + ) 54 + 55 + 40 56 async def get_root_posts( 41 57 client: httpx.AsyncClient, 42 58 scope_uri: str,
+1
core/models.py
··· 119 119 parent: str | None = None 120 120 updated_at: str | None = None 121 121 attachments: list[dict] | None = None 122 + last_activity_at: str | None = None 122 123 123 124 @property 124 125 def is_root(self) -> bool:
+56 -16
core/records.py
··· 8 8 import httpx 9 9 10 10 from core import lexicon 11 - from core.constellation import get_replies, get_root_posts 11 + from core.constellation import get_board_activity, get_replies, get_root_posts 12 12 from core.filters import filter_moderated 13 13 from core.models import AtUri, AuthError, BBS, Board, MiniDoc, Post, Record 14 - from core.slingshot import get_records_batch, resolve_identities_batch 14 + from core.slingshot import get_records_batch, get_records_by_uri, resolve_identities_batch 15 15 from core.util import now_iso 16 16 17 17 ··· 38 38 banned_dids: set[str] | None = None, 39 39 hidden_posts: set[str] | None = None, 40 40 cursor: str | None = None, 41 + page_size: int = 25, 41 42 ) -> tuple[list[Post], str | None]: 42 - """Fetch and hydrate root posts (threads) for a board.""" 43 + """Fetch threads for a board, sorted by last activity (bump order). 44 + 45 + Scans recent board activity (threads + replies) and collects unique 46 + thread URIs in the order they appear. Since Constellation returns 47 + newest posts first, the first time a thread URI appears is its most 48 + recent activity — giving us bump order naturally. 49 + """ 43 50 board_uri = str(AtUri(bbs.identity.did, lexicon.BOARD, board.slug)) 44 - backlinks = await get_root_posts(client, board_uri, cursor=cursor) 45 - records = await get_records_batch(client, backlinks.records) 51 + max_scans = 4 52 + 53 + # Phase 1: Scan board activity to find unique thread URIs 54 + # Keys are thread URIs, values are the timestamp of their last activity. 55 + last_activity: dict[str, str] = {} 56 + scan_cursor = cursor 57 + 58 + for scan in range(max_scans): 59 + if len(last_activity) >= page_size: 60 + break 61 + 62 + backlinks = await get_board_activity(client, board_uri, cursor=scan_cursor) 63 + if not backlinks.records: 64 + break 65 + 66 + records = await get_records_batch(client, backlinks.records) 67 + if banned_dids or hidden_posts: 68 + records = filter_moderated(records, banned_dids or set(), hidden_posts or set()) 69 + 70 + for record in records: 71 + thread_uri = record.value.get("root") or record.uri 72 + if thread_uri not in last_activity: 73 + last_activity[thread_uri] = record.value.get("createdAt", "") 74 + 75 + scan_cursor = backlinks.cursor 76 + if not scan_cursor: 77 + break 78 + 79 + # Phase 2: Fetch root post records for the thread URIs 80 + thread_uris = list(last_activity.keys())[:page_size] 81 + root_records = await get_records_by_uri(client, thread_uris) 82 + root_records = [record for record in root_records if not record.value.get("root")] 46 83 if banned_dids or hidden_posts: 47 - records = filter_moderated(records, banned_dids or set(), hidden_posts or set()) 84 + root_records = filter_moderated(root_records, banned_dids or set(), hidden_posts or set()) 48 85 49 - parsed = {record.uri: AtUri.parse(record.uri) for record in records} 50 - dids = [parsed_uri.did for parsed_uri in parsed.values()] 51 - authors = await resolve_identities_batch(client, dids) 86 + # Phase 3: Resolve authors and build Post objects 87 + uri_to_did = {record.uri: AtUri.parse(record.uri).did for record in root_records} 88 + authors = await resolve_identities_batch(client, list(uri_to_did.values())) 52 89 53 90 threads = [ 54 - post_from_record(record, authors[parsed[record.uri].did]) 55 - for record in records 56 - if parsed[record.uri].did in authors 91 + post_from_record(record, authors[uri_to_did[record.uri]]) 92 + for record in root_records 93 + if uri_to_did[record.uri] in authors 57 94 ] 58 - # Filter to root posts only (no root field = thread, not a reply) 59 - threads = [thread for thread in threads if thread.is_root] 60 - threads.sort(key=lambda thread: thread.created_at, reverse=True) 61 - return threads, backlinks.cursor 95 + 96 + # Set last_activity_at and sort by it (bump order) 97 + for thread in threads: 98 + thread.last_activity_at = last_activity.get(thread.uri, thread.created_at) 99 + threads.sort(key=lambda thread: thread.last_activity_at or thread.created_at, reverse=True) 100 + 101 + return threads, scan_cursor 62 102 63 103 64 104 @dataclass
+11 -1
core/slingshot.py
··· 3 3 import httpx 4 4 5 5 from core.cache import TTLCache 6 - from core.models import BacklinkRef, MiniDoc, Record 6 + from core.models import AtUri, BacklinkRef, MiniDoc, Record 7 7 8 8 BASE_URL = "https://slingshot.microcosm.blue/xrpc" 9 9 ··· 64 64 tasks = [resolve_identity(client, did) for did in dids] 65 65 results = await asyncio.gather(*tasks, return_exceptions=True) 66 66 return {r.did: r for r in results if isinstance(r, MiniDoc)} 67 + 68 + 69 + async def get_records_by_uri( 70 + client: httpx.AsyncClient, uris: list[str] 71 + ) -> list[Record]: 72 + """Fetch multiple records by AT-URI, skipping failures.""" 73 + parsed = [AtUri.parse(uri) for uri in uris] 74 + tasks = [get_record(client, at_uri.did, at_uri.collection, at_uri.rkey) for at_uri in parsed] 75 + results = await asyncio.gather(*tasks, return_exceptions=True) 76 + return [result for result in results if isinstance(result, Record)] 67 77 68 78 69 79 async def get_records_batch(
+1 -1
telnet/server.py
··· 124 124 await write(writer, " No threads yet.\r\n") 125 125 else: 126 126 for index, thread in enumerate(threads, 1): 127 - date = format_datetime_utc(thread.created_at) 127 + date = format_datetime_utc(thread.last_activity_at or thread.created_at) 128 128 await write( 129 129 writer, f" {index}. {thread.title} · {thread.author.handle} · {date}\r\n" 130 130 )
+1 -1
tui/screens/board.py
··· 70 70 thread_list = self.query_one("#thread-list", ListView) 71 71 thread_list.clear() 72 72 for thread in self.threads: 73 - label = f" {thread.title} — {thread.author.handle} · {format_datetime(thread.created_at)}" 73 + label = f" {thread.title} — {thread.author.handle} · {format_datetime(thread.last_activity_at or thread.created_at)}" 74 74 await thread_list.append(ListItem(Static(label), name=thread.uri)) 75 75 76 76 if self.threads:
+14
web/src/lib/atproto.ts
··· 82 82 return getRecord(did, collection, rkey); 83 83 } 84 84 85 + export async function getRecordsByUri( 86 + uris: string[], 87 + ): Promise<ATRecord[]> { 88 + const results = await Promise.allSettled( 89 + uris.map((uri) => getRecordByUri(uri)), 90 + ); 91 + return results 92 + .filter( 93 + (result): result is PromiseFulfilledResult<ATRecord> => 94 + result.status === "fulfilled", 95 + ) 96 + .map((result) => result.value); 97 + } 98 + 85 99 export async function getRecordsBatch( 86 100 refs: BacklinkRef[], 87 101 ): Promise<ATRecord[]> {
+1 -1
web/src/pages/Board.tsx
··· 133 133 key={t.uri} 134 134 to={`/bbs/${handle}/thread/${t.did}/${t.rkey}`} 135 135 title={t.title} 136 - meta={`${t.handle} · ${relativeDate(t.createdAt)}`} 136 + meta={`${t.handle} · ${relativeDate(t.lastActivityAt)}`} 137 137 preview={t.body.substring(0, 120)} 138 138 /> 139 139 ))
+59 -11
web/src/router/loaders/board.ts
··· 3 3 import { 4 4 getBacklinks, 5 5 getRecordsBatch, 6 + getRecordsByUri, 6 7 resolveIdentitiesBatch, 7 - type ATRecord, 8 8 } from "../../lib/atproto"; 9 9 import { POST, BOARD } from "../../lib/lexicon"; 10 10 import { makeAtUri, parseAtUri } from "../../lib/util"; ··· 20 20 title: string; 21 21 body: string; 22 22 createdAt: string; 23 + lastActivityAt: string; 23 24 } 24 25 26 + const MAX_SCANS = 4; 27 + const PAGE_SIZE = 25; 28 + 29 + /** 30 + * Fetch threads for a board, sorted by last activity (bump order). 31 + * 32 + * Scans recent board activity (threads + replies) and collects unique 33 + * thread URIs in the order they appear. Since Constellation returns 34 + * newest posts first, the first time a thread URI appears is its most 35 + * recent activity — giving us bump order naturally. 36 + */ 25 37 export async function hydrateThreadPage( 26 38 bbs: BBS, 27 39 slug: string, 28 40 cursor?: string, 29 41 ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { 30 42 const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); 31 - const backlinks = await getBacklinks(boardUri, `${POST}:scope`, 50, cursor); 32 - const records = await getRecordsBatch(backlinks.records); 33 - const filtered = records.filter((record) => { 43 + 44 + // Phase 1: Scan board activity to find unique thread URIs. 45 + // Keys are thread URIs, values are the timestamp of their last activity. 46 + const lastActivity = new Map<string, string>(); 47 + let scanCursor = cursor; 48 + 49 + for (let scanCount = 0; scanCount < MAX_SCANS; scanCount++) { 50 + if (lastActivity.size >= PAGE_SIZE) break; 51 + 52 + const backlinks = await getBacklinks(boardUri, `${POST}:scope`, 100, scanCursor); 53 + if (!backlinks.records.length) break; 54 + 55 + const records = await getRecordsBatch(backlinks.records); 56 + for (const record of records) { 57 + if (!is(postSchema, record.value)) continue; 58 + const value = record.value as unknown as XyzAtbbsPost.Main; 59 + const threadUri = value.root ?? record.uri; 60 + if (!lastActivity.has(threadUri)) { 61 + lastActivity.set(threadUri, value.createdAt); 62 + } 63 + } 64 + 65 + scanCursor = backlinks.cursor; 66 + if (!scanCursor) break; 67 + } 68 + 69 + // Phase 2: Fetch root post records for the thread URIs. 70 + const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE); 71 + const rootRecords = await getRecordsByUri(threadUris); 72 + 73 + const validRoots = rootRecords.filter((record) => { 34 74 if (!is(postSchema, record.value)) return false; 35 75 const value = record.value as unknown as XyzAtbbsPost.Main; 36 - return value.title && !value.root; // root posts with titles = threads 76 + return value.title && !value.root; 37 77 }); 78 + 79 + // Phase 3: Resolve authors and build ThreadItems. 38 80 const authors = await resolveIdentitiesBatch( 39 - filtered.map((record) => parseAtUri(record.uri).did), 81 + validRoots.map((record) => parseAtUri(record.uri).did), 40 82 ); 41 - const threads: ThreadItem[] = filtered 42 - .filter((record) => parseAtUri(record.uri).did in authors) 43 - .map((record: ATRecord) => { 83 + 84 + const threads: ThreadItem[] = validRoots 85 + .filter((record) => { 86 + const authorDid = parseAtUri(record.uri).did; 87 + return authorDid in authors; 88 + }) 89 + .map((record) => { 44 90 const { did, rkey } = parseAtUri(record.uri); 45 91 const value = record.value as unknown as XyzAtbbsPost.Main; 46 92 return { ··· 51 97 title: value.title ?? "", 52 98 body: value.body, 53 99 createdAt: value.createdAt, 100 + lastActivityAt: lastActivity.get(record.uri) ?? value.createdAt, 54 101 }; 55 102 }) 56 - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 57 - return { threads, cursor: backlinks.cursor ?? null }; 103 + .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt)); 104 + 105 + return { threads, cursor: scanCursor ?? null }; 58 106 } 59 107 60 108 export async function boardLoader({ params }: LoaderFunctionArgs) {