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.

fmt

+106 -35
+6 -2
core/records.py
··· 93 93 page_refs = all_refs[start : start + page_size] 94 94 95 95 if not page_refs: 96 - return RepliesPage(replies=[], page=page, total_pages=total_pages, total_replies=total) 96 + return RepliesPage( 97 + replies=[], page=page, total_pages=total_pages, total_replies=total 98 + ) 97 99 98 100 # Hydrate only this page 99 101 records = await get_records_batch(client, page_refs) ··· 118 120 if parsed[r.uri].did in authors 119 121 ] 120 122 replies.sort(key=lambda t: t.created_at) 121 - return RepliesPage(replies=replies, page=page, total_pages=total_pages, total_replies=total) 123 + return RepliesPage( 124 + replies=replies, page=page, total_pages=total_pages, total_replies=total 125 + ) 122 126 123 127 124 128 async def _try_refresh_token(client, session, session_updater):
+4
justfile
··· 14 14 js: 15 15 npx esbuild web/ts/main.ts --bundle --outfile=web/static/app.js --minify 16 16 17 + fmt: 18 + uv format 19 + npx prettier --write web/ts/ 20 + 17 21 tui: 18 22 uv run python -m tui 19 23
+17
package-lock.json
··· 7 7 "devDependencies": { 8 8 "@tailwindcss/cli": "^4", 9 9 "esbuild": "^0.25", 10 + "prettier": "^3", 10 11 "tailwindcss": "^4" 11 12 } 12 13 }, ··· 1541 1542 }, 1542 1543 "funding": { 1543 1544 "url": "https://github.com/sponsors/jonschlinkert" 1545 + } 1546 + }, 1547 + "node_modules/prettier": { 1548 + "version": "3.8.1", 1549 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", 1550 + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", 1551 + "dev": true, 1552 + "license": "MIT", 1553 + "bin": { 1554 + "prettier": "bin/prettier.cjs" 1555 + }, 1556 + "engines": { 1557 + "node": ">=14" 1558 + }, 1559 + "funding": { 1560 + "url": "https://github.com/prettier/prettier?sponsor=1" 1544 1561 } 1545 1562 }, 1546 1563 "node_modules/source-map-js": {
+1
package.json
··· 3 3 "devDependencies": { 4 4 "esbuild": "^0.25", 5 5 "@tailwindcss/cli": "^4", 6 + "prettier": "^3", 6 7 "tailwindcss": "^4" 7 8 } 8 9 }
+3
pyproject.toml
··· 24 24 [tool.hatch.build.targets.wheel] 25 25 packages = ["cli", "core", "web", "tui"] 26 26 exclude = ["web/ts"] 27 + 28 + [dependency-groups] 29 + dev = []
+3 -1
tui/screens/activity.py
··· 91 91 from tui.screens.thread import ThreadScreen 92 92 93 93 focus_reply = item.get("reply_uri") 94 - self.app.push_screen(ThreadScreen(bbs, handle, thread, focus_reply=focus_reply)) 94 + self.app.push_screen( 95 + ThreadScreen(bbs, handle, thread, focus_reply=focus_reply) 96 + ) 95 97 except Exception: 96 98 self.notify("Could not open thread.", severity="error") 97 99
+6 -2
tui/screens/thread.py
··· 22 22 Binding("ctrl+s", "save_attachment", "save attachments", show=False), 23 23 ] 24 24 25 - def __init__(self, bbs: BBS, handle: str, thread: Thread, focus_reply: str | None = None) -> None: 25 + def __init__( 26 + self, bbs: BBS, handle: str, thread: Thread, focus_reply: str | None = None 27 + ) -> None: 26 28 super().__init__() 27 29 self.bbs = bbs 28 30 self.handle = handle ··· 70 72 self._focus_reply = None # only use on first load 71 73 72 74 def _update_page_status(self) -> None: 73 - text = f"page {self._page} of {self._total_pages}" if self._total_pages > 1 else "" 75 + text = ( 76 + f"page {self._page} of {self._total_pages}" if self._total_pages > 1 else "" 77 + ) 74 78 self.query_one("#page-status-top", Static).update(text) 75 79 self.query_one("#page-status-bottom", Static).update(text) 76 80
+3
uv.lock
··· 120 120 { name = "textual", specifier = ">=8.2.2" }, 121 121 ] 122 122 123 + [package.metadata.requires-dev] 124 + dev = [] 125 + 123 126 [[package]] 124 127 name = "attrs" 125 128 version = "26.1.0"
+3 -1
web/routes.py
··· 277 277 ) 278 278 279 279 try: 280 - result = await hydrate_replies(client, bbs, dummy_thread, page=page, focus_reply=focus_reply) 280 + result = await hydrate_replies( 281 + client, bbs, dummy_thread, page=page, focus_reply=focus_reply 282 + ) 281 283 except Exception: 282 284 return {"replies": [], "page": 1, "total_pages": 1, "total_replies": 0} 283 285
+10 -3
web/ts/lib/atproto.ts
··· 37 37 cursor?: string; 38 38 } 39 39 40 - export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } { 40 + export function parseAtUri(uri: string): { 41 + did: string; 42 + collection: string; 43 + rkey: string; 44 + } { 41 45 const parts = uri.split("/"); 42 46 return { did: parts[2], collection: parts[3], rkey: parts[4] }; 43 47 } ··· 77 81 refs.map((r) => getRecord(r.did, r.collection, r.rkey)), 78 82 ); 79 83 return results 80 - .filter((r): r is PromiseFulfilledResult<ATRecord> => r.status === "fulfilled") 84 + .filter( 85 + (r): r is PromiseFulfilledResult<ATRecord> => r.status === "fulfilled", 86 + ) 81 87 .map((r) => r.value); 82 88 } 83 89 ··· 136 142 return true; 137 143 }); 138 144 139 - if (!filtered.length) return { records: [], cursor: backlinks.cursor ?? null }; 145 + if (!filtered.length) 146 + return { records: [], cursor: backlinks.cursor ?? null }; 140 147 141 148 const dids = filtered.map((r) => parseAtUri(r.uri).did); 142 149 const authors = await resolveIdentitiesBatch(dids);
+21 -18
web/ts/pages/account.ts
··· 26 26 function initTabs() { 27 27 const panels = ["inbox", "bbs"]; 28 28 29 - document.querySelectorAll<HTMLElement>(".tab-btn[data-tab]").forEach((btn) => { 30 - btn.addEventListener("click", () => { 31 - const name = btn.dataset.tab!; 29 + document 30 + .querySelectorAll<HTMLElement>(".tab-btn[data-tab]") 31 + .forEach((btn) => { 32 + btn.addEventListener("click", () => { 33 + const name = btn.dataset.tab!; 32 34 33 - document.querySelectorAll(".tab-btn").forEach((b) => { 34 - b.classList.remove("text-neutral-200", "border-neutral-200"); 35 - b.classList.add("text-neutral-500", "border-transparent"); 36 - }); 37 - btn.classList.remove("text-neutral-500", "border-transparent"); 38 - btn.classList.add("text-neutral-200", "border-neutral-200"); 35 + document.querySelectorAll(".tab-btn").forEach((b) => { 36 + b.classList.remove("text-neutral-200", "border-neutral-200"); 37 + b.classList.add("text-neutral-500", "border-transparent"); 38 + }); 39 + btn.classList.remove("text-neutral-500", "border-transparent"); 40 + btn.classList.add("text-neutral-200", "border-neutral-200"); 39 41 40 - for (const p of panels) { 41 - document.getElementById(`panel-${p}`)?.classList.toggle("hidden", p !== name); 42 - } 42 + for (const p of panels) { 43 + document 44 + .getElementById(`panel-${p}`) 45 + ?.classList.toggle("hidden", p !== name); 46 + } 47 + }); 43 48 }); 44 - }); 45 49 } 46 50 47 51 // --- Render --- ··· 144 148 }), 145 149 ...replyRecords.map(async (rr) => { 146 150 try { 147 - const { records } = await fetchAndHydrate( 148 - rr.uri, 149 - `${REPLY}:quote`, 150 - { limit: BACKLINK_LIMIT, excludeDid: did }, 151 - ); 151 + const { records } = await fetchAndHydrate(rr.uri, `${REPLY}:quote`, { 152 + limit: BACKLINK_LIMIT, 153 + excludeDid: did, 154 + }); 152 155 return recordsToInboxItems( 153 156 records, 154 157 "quote",
+29 -8
web/ts/pages/thread.ts
··· 24 24 // --- Quote UI --- 25 25 26 26 function quoteReply(uri: string, handle: string) { 27 - const quoteUri = document.getElementById("quote-uri") as HTMLInputElement | null; 27 + const quoteUri = document.getElementById( 28 + "quote-uri", 29 + ) as HTMLInputElement | null; 28 30 const previewText = document.getElementById("quote-preview-text"); 29 31 if (quoteUri) quoteUri.value = uri; 30 32 if (previewText) previewText.textContent = `quoting ${handle}`; 31 33 document.getElementById("quote-preview")?.classList.remove("hidden"); 32 - (document.getElementById("reply-body") as HTMLTextAreaElement | null)?.focus(); 34 + ( 35 + document.getElementById("reply-body") as HTMLTextAreaElement | null 36 + )?.focus(); 33 37 } 34 38 35 39 function clearQuote() { ··· 136 140 if (active) { 137 141 btn.className = "text-neutral-200 bg-neutral-800 rounded px-3 py-1"; 138 142 } else if (page !== null) { 139 - btn.className = "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 rounded px-3 py-1"; 143 + btn.className = 144 + "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 rounded px-3 py-1"; 140 145 btn.addEventListener("click", () => goToPage(page)); 141 146 } else { 142 147 btn.className = "text-neutral-600 px-2 py-1 cursor-default"; ··· 238 243 url.searchParams.set("page", String(p)); 239 244 history.pushState(null, "", url.toString()); 240 245 loadReplyPage(p, threadDid, threadTid, handle, userDid, sysopDid); 241 - document.getElementById("replies-nav-top")?.scrollIntoView({ behavior: "smooth" }); 246 + document 247 + .getElementById("replies-nav-top") 248 + ?.scrollIntoView({ behavior: "smooth" }); 242 249 }; 243 250 updateNavs(data.page, data.total_pages, goToPage); 244 251 } ··· 262 269 263 270 document.getElementById("quote-clear")?.addEventListener("click", clearQuote); 264 271 document.getElementById("replies")?.addEventListener("click", (e) => { 265 - const btn = (e.target as HTMLElement).closest(".quote-btn") as HTMLElement | null; 272 + const btn = (e.target as HTMLElement).closest( 273 + ".quote-btn", 274 + ) as HTMLElement | null; 266 275 if (btn) quoteReply(btn.dataset.uri!, btn.dataset.handle!); 267 276 }); 268 277 269 278 const params = new URLSearchParams(window.location.search); 270 279 const initialPage = parseInt(params.get("page") ?? "1", 10); 271 280 const focusReply = params.get("reply") ?? undefined; 272 - loadReplyPage(initialPage, threadDid, threadTid, handle, userDid, sysopDid, focusReply); 281 + loadReplyPage( 282 + initialPage, 283 + threadDid, 284 + threadTid, 285 + handle, 286 + userDid, 287 + sysopDid, 288 + focusReply, 289 + ); 273 290 274 291 window.addEventListener("popstate", () => { 275 - const p = parseInt(new URLSearchParams(window.location.search).get("page") ?? "1", 10); 292 + const p = parseInt( 293 + new URLSearchParams(window.location.search).get("page") ?? "1", 294 + 10, 295 + ); 276 296 const container = document.getElementById("replies")!; 277 - container.innerHTML = '<p id="replies-loading" class="text-neutral-500">Loading replies...</p>'; 297 + container.innerHTML = 298 + '<p id="replies-loading" class="text-neutral-500">Loading replies...</p>'; 278 299 hideNavs(); 279 300 loadReplyPage(p, threadDid, threadTid, handle, userDid, sysopDid); 280 301 });