···11from core.models import Record
223344-def filter_moderated(records: list[Record], banned_dids: set[str], hidden_posts: set[str]) -> list[Record]:
44+def filter_moderated(
55+ records: list[Record], banned_dids: set[str], hidden_posts: set[str]
66+) -> list[Record]:
57 """Remove records from banned users or hidden by the sysop."""
68 return [
77- r for r in records
99+ r
1010+ for r in records
811 if r.uri.split("/")[2] not in banned_dids and r.uri not in hidden_posts
912 ]
···5353 """Resolve multiple DIDs concurrently, skipping failures."""
5454 tasks = [resolve_identity(client, did) for did in dids]
5555 results = await asyncio.gather(*tasks, return_exceptions=True)
5656- return {
5757- r.did: r for r in results if isinstance(r, MiniDoc)
5858- }
5656+ return {r.did: r for r in results if isinstance(r, MiniDoc)}
595760586159async def get_records_batch(
6260 client: httpx.AsyncClient, refs: list[BacklinkRef]
6361) -> list[Record]:
6462 """Fetch multiple records concurrently, skipping failures."""
6565- tasks = [
6666- get_record(client, ref.did, ref.collection, ref.rkey) for ref in refs
6767- ]
6363+ tasks = [get_record(client, ref.did, ref.collection, ref.rkey) for ref in refs]
6864 results = await asyncio.gather(*tasks, return_exceptions=True)
6965 return [r for r in results if isinstance(r, Record)]
+3
tui/app.py
···8080 def _restore_session(self) -> None:
8181 """Load the most recent session from the database."""
8282 import sqlite3
8383+8384 try:
8485 con = sqlite3.connect(self.session_store.db_path)
8586 con.row_factory = sqlite3.Row
···9697 self.push_screen(LogoutConfirmScreen())
9798 else:
9899 from tui.screens.login import LoginScreen
100100+99101 self.push_screen(LoginScreen())
100102101103 def do_logout(self) -> None:
···112114 self.notify("Log in to see your inbox.", severity="warning")
113115 return
114116 from tui.screens.activity import ActivityScreen
117117+115118 self.push_screen(ActivityScreen())
116119117120 def watch_screen(self) -> None:
+5-1
tui/fetchers.py
···11"""TUI data fetching — thin wrappers around core.records."""
2233# Re-export from core for backwards compatibility
44-from core.records import delete_record, hydrate_replies as fetch_replies, hydrate_threads as fetch_threads
44+from core.records import (
55+ delete_record,
66+ hydrate_replies as fetch_replies,
77+ hydrate_threads as fetch_threads,
88+)
···19192020 def compose(self) -> ComposeResult:
2121 from tui.widgets.breadcrumb import Breadcrumb
2222+2223 yield Breadcrumb(
2324 ("@bbs", 1),
2425 (self.handle, 0),
···4142 yield ListView(
4243 *[
4344 ListItem(
4444- Static(f" {item.title} — {format_datetime(item.created_at)}"),
4545+ Static(
4646+ f" {item.title} — {format_datetime(item.created_at)}"
4747+ ),
4548 name=str(i),
4649 )
4750 for i, item in enumerate(self.bbs.news)
···7376 board = next((b for b in self.bbs.site.boards if b.slug == slug), None)
7477 if board:
7578 from tui.screens.board import BoardScreen
7979+7680 self.app.push_screen(BoardScreen(self.bbs, self.handle, board))
7781 elif event.list_view.id == "news-list":
7882 idx = int(event.item.name)
7983 if 0 <= idx < len(self.bbs.news):
8084 from tui.screens.news import NewsScreen
8181- self.app.push_screen(NewsScreen(self.bbs, self.handle, self.bbs.news[idx]))
8585+8686+ self.app.push_screen(
8787+ NewsScreen(self.bbs, self.handle, self.bbs.news[idx])
8888+ )
+28-14
tui/screens/thread.py
···11+from textual import work
12from textual.app import ComposeResult
23from textual.binding import Binding
34from textual.containers import VerticalScroll
45from textual.screen import Screen
56from textual.widgets import Button, Footer
66-from textual import work
7788from core.models import BBS, Thread
99from tui.fetchers import delete_record, fetch_replies
1010+from tui.util import require_session
1011from tui.widgets.post import Post
11121213···28292930 def compose(self) -> ComposeResult:
3031 board_slug = self.thread.board_uri.split("/")[-1]
3131- board_name = next((b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug)
3232+ board_name = next(
3333+ (b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug
3434+ )
3235 from tui.widgets.breadcrumb import Breadcrumb
3636+3337 yield Breadcrumb(
3438 ("@bbs", 3),
3539 (self.bbs.site.name, 2),
···6266 client = self.app.http_client
6367 try:
6468 replies, self.next_cursor = await fetch_replies(
6565- client, self.bbs, self.thread, cursor=cursor,
6969+ client,
7070+ self.bbs,
7171+ self.thread,
7272+ cursor=cursor,
6673 )
6774 except Exception:
6875 self.notify("Failed to load replies.", severity="error")
···96103 )
9710498105 if self.next_cursor:
9999- await scroll.mount(
100100- Button("next page →", id="next-page")
101101- )
106106+ await scroll.mount(Button("next page →", id="next-page"))
102107103108 def refresh_data(self) -> None:
104109 self._do_refresh()
···116121 client = self.app.http_client
117122 try:
118123 replies, self.next_cursor = await fetch_replies(
119119- client, self.bbs, self.thread,
124124+ client,
125125+ self.bbs,
126126+ self.thread,
120127 )
121128 except Exception:
122129 self.notify("Failed to load replies.", severity="error")
···157164 self.load_replies(cursor=self.next_cursor)
158165159166 def action_reply(self) -> None:
160160- session = self.app.user_session
167167+ session = require_session(self)
161168 if not session:
162162- return
163163- if session["did"] in self.bbs.site.banned_dids:
164164- self.notify("You have been banned from this BBS.", severity="error")
165169 return
166170167171 # If focused on a reply, quote it
168172 quote = None
169173 focused = self.focused
170170- if isinstance(focused, Post) and focused.collection == "xyz.atboards.reply" and focused.record_uri:
174174+ if (
175175+ isinstance(focused, Post)
176176+ and focused.collection == "xyz.atboards.reply"
177177+ and focused.record_uri
178178+ ):
171179 quote = self._replies_map.get(focused.record_uri)
172180173181 from tui.screens.compose import ComposeReplyScreen
174174- self.app.push_screen(ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote))
182182+183183+ self.app.push_screen(
184184+ ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote)
185185+ )
175186176187 def action_delete(self) -> None:
177188 session = self.app.user_session
···192203 session = self.app.user_session
193204 try:
194205 await delete_record(
195195- self.app.http_client, session, post.collection, post.rkey,
206206+ self.app.http_client,
207207+ session,
208208+ post.collection,
209209+ post.rkey,
196210 )
197211 except Exception:
198212 self.notify("Failed to delete.", severity="error")
+12
tui/util.py
···11"""TUI utilities — delegates to core."""
2233from core.util import format_datetime_local as format_datetime
44+55+66+def require_session(screen) -> dict | None:
77+ """Return the user session if logged in and not banned, else notify and return None."""
88+ session = screen.app.user_session
99+ if not session:
1010+ screen.notify("You are not logged in.", severity="error")
1111+ return None
1212+ if session["did"] in screen.bbs.site.banned_dids:
1313+ screen.notify("You have been banned from this BBS.", severity="error")
1414+ return None
1515+ return session
+3-1
tui/widgets/breadcrumb.py
···7474 # Show logged-in user on the right
7575 session = getattr(self.app, "user_session", None)
7676 if session:
7777- yield Static(f" {session['handle']} ", classes="breadcrumb-user", markup=False)
7777+ yield Static(
7878+ f" {session['handle']} ", classes="breadcrumb-user", markup=False
7979+ )
78807981 for i, (label, pop_count) in enumerate(self._segments):
8082 if i > 0:
+1
web/app.py
···3939 @app.before_request
4040 async def load_user():
4141 from quart import g, session
4242+4243 did = session.get("did")
4344 if did:
4445 g.user = app.session_store.get_session(did)
+25-10
web/routes.py
···44from quart import Blueprint, current_app, render_template, request
5566from core.models import (
77- BBSNotFoundError, NetworkError, NoBBSError, Thread,
77+ BBSNotFoundError,
88+ NetworkError,
99+ NoBBSError,
1010+ Thread,
811)
912from core.records import hydrate_replies, hydrate_threads
1013from core.resolver import resolve_bbs
···2023async def check_banned(bbs):
2124 """Return an error response if the current user is banned, or None."""
2225 from quart import g
2626+2327 if g.user and g.user.get("did") in bbs.site.banned_dids:
2424- return await render_template("error.html", message="You have been banned from this BBS."), 403
2828+ return await render_template(
2929+ "error.html", message="You have been banned from this BBS."
3030+ ), 403
2531 return None
26322733···3844@bp.route("/api/inbox")
3945async def api_inbox():
4046 from quart import g
4747+4148 if not g.user:
4249 return {"inbox": [], "cursor": None}
43504451 from core.records import fetch_inbox
5252+4553 client = current_app.http_client
4654 cursor = request.args.get("cursor")
4755 offset = int(cursor) if cursor else 0
4856 limit = 20
49575058 all_items = await fetch_inbox(client, g.user["did"], g.user["pds_url"])
5151- page = all_items[offset:offset + limit]
5959+ page = all_items[offset : offset + limit]
5260 next_cursor = str(offset + limit) if offset + limit < len(all_items) else None
53615462 return {"inbox": page, "cursor": next_cursor}
···8290 for r in raw:
8391 did = r["did"]
8492 if did in authors:
8585- bbses.append({
8686- "handle": authors[did].handle,
8787- "name": r["record"].get("name", ""),
8888- "description": r["record"].get("description", ""),
8989- })
9393+ bbses.append(
9494+ {
9595+ "handle": authors[did].handle,
9696+ "name": r["record"].get("name", ""),
9797+ "description": r["record"].get("description", ""),
9898+ }
9999+ )
90100 except Exception:
91101 pass
92102 return {"bbses": bbses}
···158168 return {"threads": [], "cursor": None}
159169160170 try:
161161- threads, next_cursor = await hydrate_threads(client, bbs, current_board, cursor=cursor)
171171+ threads, next_cursor = await hydrate_threads(
172172+ client, bbs, current_board, cursor=cursor
173173+ )
162174 except Exception:
163175 return {"threads": [], "cursor": None}
164176···251263 # Build a minimal Thread object for hydrate_replies
252264 thread_uri = f"at://{did}/xyz.atboards.thread/{tid}"
253265 from core.models import MiniDoc
266266+254267 dummy_thread = Thread(
255268 uri=thread_uri,
256269 board_uri="",
···261274 )
262275263276 try:
264264- replies, next_cursor = await hydrate_replies(client, bbs, dummy_thread, cursor=cursor)
277277+ replies, next_cursor = await hydrate_replies(
278278+ client, bbs, dummy_thread, cursor=cursor
279279+ )
265280 except Exception:
266281 return {"replies": [], "cursor": None}
267282