···11-from core import lexicon
21from core.models import AtUri, Record
3243···76) -> list[Record]:
87 """Remove records from banned users or hidden by the sysop."""
98 return [
1010- r
1111- for r in records
1212- if AtUri.parse(r.uri).did not in banned_dids and r.uri not in hidden_posts
99+ record
1010+ for record in records
1111+ if AtUri.parse(record.uri).did not in banned_dids
1212+ and record.uri not in hidden_posts
1313 ]
···123123 if not threads:
124124 await write(writer, " No threads yet.\r\n")
125125 else:
126126- for i, t in enumerate(threads, 1):
127127- date = format_datetime_utc(t.created_at)
126126+ for index, thread in enumerate(threads, 1):
127127+ date = format_datetime_utc(thread.created_at)
128128 await write(
129129- writer, f" {i}. {t.title} · {t.author.handle} · {date}\r\n"
129129+ writer, f" {index}. {thread.title} · {thread.author.handle} · {date}\r\n"
130130 )
131131132132 cmds = ["[#] open thread"]
···149149150150151151async def show_replies(writer, replies):
152152- for r in replies:
152152+ for reply in replies:
153153 await write(
154154- writer, f" {r.author.handle} · {format_datetime_utc(r.created_at)}\r\n"
154154+ writer, f" {reply.author.handle} · {format_datetime_utc(reply.created_at)}\r\n"
155155 )
156156- for line in r.body.splitlines():
156156+ for line in reply.body.splitlines():
157157 await write(writer, f" {line}\r\n")
158158 await write(writer, "\r\n")
159159
+9-19
tui/screens/activity.py
···66from textual.screen import Screen
77from textual.widgets import Footer, Static
8899-from core.models import AtUri, Thread
1010-from core.records import fetch_inbox
99+from core.models import AtUri, Post as PostModel
1010+from core.records import fetch_inbox, post_from_record
1111from core.resolver import resolve_bbs
1212from core.slingshot import get_record, resolve_identity
1313from tui.screens.thread import ThreadScreen
···3333 with VerticalScroll(id="activity-scroll"):
3434 yield Static("Inbox", classes="title")
3535 yield Static(
3636- "Replies to your threads and quotes of your replies.",
3636+ "Replies to your threads from other users.",
3737 classes="subtitle",
3838 )
3939 yield Static("Loading...", id="activity-loading")
···6969 async def _navigate(self, item: dict) -> None:
7070 parsed = AtUri.parse(item["thread_uri"])
7171 thread_did = parsed.did
7272- thread_tid = parsed.rkey
7272+ thread_rkey = parsed.rkey
7373 handle = item.get("bbs_handle") or self.app.user_session.get("handle", "")
74747575 client = self.app.http_client
7676 try:
7777 bbs, rec, author = await asyncio.gather(
7878 resolve_bbs(client, handle),
7979- get_record(client, thread_did, "xyz.atboards.thread", thread_tid),
7979+ get_record(client, thread_did, "xyz.atbbs.post", thread_rkey),
8080 resolve_identity(client, thread_did),
8181 )
8282- thread = Thread(
8383- uri=rec.uri,
8484- board_uri=rec.value["board"],
8585- title=rec.value["title"],
8686- body=rec.value["body"],
8787- created_at=rec.value["createdAt"],
8888- author=author,
8989- updated_at=rec.value.get("updatedAt"),
9090- attachments=rec.value.get("attachments"),
9191- )
8282+ thread = post_from_record(rec, author)
9283 self.app.push_screen(
9384 ThreadScreen(bbs, handle, thread, focus_reply=item.get("reply_uri"))
9485 )
···120111 return
121112122113 for item in self._items[:50]:
123123- title = (
124124- item["thread_title"] if item["type"] == "reply" else "quoted your reply"
125125- )
126114 if item["type"] == "reply":
127127- title = f"on: {title}"
115115+ title = f"on: {item['thread_title']}"
116116+ else:
117117+ title = "replied to your reply"
128118 await scroll.mount(
129119 Post(
130120 author=item["handle"],
···6677from core import lexicon, limits
88from core.models import AtUri, AuthError, BBS, Board
99-from core.records import create_thread_record
99+from core.records import create_post_record
1010from tui.util import require_session
1111from tui.widgets.breadcrumb import Breadcrumb
1212from tui.screens.compose.upload import upload_file
···3636 yield Input(
3737 placeholder="Thread title",
3838 id="thread-title",
3939- max_length=limits.THREAD_TITLE,
3939+ max_length=limits.POST_TITLE,
4040 )
4141 yield TextArea(id="thread-body", language=None)
4242 yield Input(placeholder="attach file (path, optional)", id="thread-file")
···5959 if not title or not body:
6060 self.notify("Title and body cannot be empty.", severity="error")
6161 return
6262- if len(body) > limits.THREAD_BODY:
6262+ if len(body) > limits.POST_BODY:
6363 self.notify(
6464- f"Body too long ({len(body)}/{limits.THREAD_BODY}).", severity="error"
6464+ f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error"
6565 )
6666 return
6767···7575 return
76767777 try:
7878- resp = await create_thread_record(
7878+ resp = await create_post_record(
7979 self.app.http_client,
8080 session,
8181- board_uri,
8282- title,
8383- body,
8181+ scope=board_uri,
8282+ body=body,
8383+ title=title,
8484 attachments=attachments or None,
8585 )
8686 resp.raise_for_status()
-6
tui/screens/home.py
···9595 self.notify("Could not reach the network.", severity="error")
9696 return
97979898- # Check if banned
9999- session = self.app.user_session
100100- if session and bbs.site.is_banned(session.get("did")):
101101- self.notify("You have been banned from this BBS.", severity="error")
102102- return
103103-10498 self.app.push_screen(SiteScreen(bbs, handle))
10599 self.query_one("#handle-input", Input).value = ""
106100
···1010from textual.widgets import Footer, Static
11111212from core import lexicon
1313-from core.models import BBS, AtUri, AuthError, Reply, Thread
1313+from core.models import BBS, AtUri, AuthError, Post as PostModel
1414from core.records import (
1515 create_ban_record,
1616 create_hidden_record,
1717 delete_record,
1818- reply_from_record,
1818+ post_from_record,
1919)
2020from core.resolver import invalidate_bbs_cache
2121from core.records import hydrate_replies as fetch_replies
···3939 ]
40404141 def __init__(
4242- self, bbs: BBS, handle: str, thread: Thread, focus_reply: str | None = None
4242+ self, bbs: BBS, handle: str, thread: PostModel, focus_reply: str | None = None
4343 ) -> None:
4444 super().__init__()
4545 self.bbs = bbs
···4848 self._focus_reply = focus_reply
4949 self._page: int = 1
5050 self._total_pages: int = 1
5151- self._replies_map: dict[str, Reply] = {}
5151+ self._replies_map: dict[str, PostModel] = {}
52525353 def compose(self) -> ComposeResult:
5454- board_slug = AtUri.parse(self.thread.board_uri).rkey
5454+ scope_parsed = AtUri.parse(self.thread.scope)
5555+ board_slug = scope_parsed.rkey
5556 board_name = next(
5657 (b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug
5758 )
···5960 ("@bbs", 3),
6061 (self.bbs.site.name, 2),
6162 (board_name, 1),
6262- (self.thread.title, 0),
6363+ (self.thread.title or "", 0),
6364 )
6465 with VerticalScroll(id="thread-scroll"):
6566 yield Post(
···7071 author_did=self.thread.author.did,
7172 author_pds=self.thread.author.pds,
7273 record_uri=self.thread.uri,
7373- collection=lexicon.THREAD,
7474+ collection=lexicon.POST,
7475 attachments=self.thread.attachments,
7576 )
7677 yield Static("", id="page-status-top", classes="page-status")
···9192 self.query_one("#page-status-top", Static).update(text)
9293 self.query_one("#page-status-bottom", Static).update(text)
93949595+ def _is_reply_widget(self, post: Post) -> bool:
9696+ return post.record_uri is not None and post.record_uri != self.thread.uri
9797+9498 def _clear_replies(self) -> None:
9599 for post in self.query(Post):
9696- if post.collection == lexicon.REPLY:
100100+ if self._is_reply_widget(post):
97101 post.remove()
9810299103 @work(exclusive=True)
···120124 for reply in result.replies:
121125 self._replies_map[reply.uri] = reply
122126123123- # Fetch any quoted replies not already known (in parallel)
124124- missing = [
125125- reply.quote
127127+ # Fetch any parent replies not already known (in parallel)
128128+ missing_parents = [
129129+ reply.parent
126130 for reply in result.replies
127127- if reply.quote and reply.quote not in self._replies_map
131131+ if reply.parent and reply.parent not in self._replies_map
128132 ]
129133130130- async def fetch_quote(uri: str):
134134+ async def fetch_parent(uri: str):
131135 parsed = AtUri.parse(uri)
132136 record, author = await asyncio.gather(
133137 get_record(client, parsed.did, parsed.collection, parsed.rkey),
134138 resolve_identity(client, parsed.did),
135139 )
136136- return uri, reply_from_record(record, author)
140140+ return uri, post_from_record(record, author)
137141138138- if missing:
139139- quote_results = await asyncio.gather(
140140- *[fetch_quote(uri) for uri in missing],
142142+ if missing_parents:
143143+ parent_results = await asyncio.gather(
144144+ *[fetch_parent(uri) for uri in missing_parents],
141145 return_exceptions=True,
142146 )
143143- for quote_result in quote_results:
144144- if isinstance(quote_result, tuple):
145145- self._replies_map[quote_result[0]] = quote_result[1]
147147+ for parent_result in parent_results:
148148+ if isinstance(parent_result, tuple):
149149+ self._replies_map[parent_result[0]] = parent_result[1]
146150147151 for reply in result.replies:
148148- quote_text = None
149149- if reply.quote and reply.quote in self._replies_map:
150150- quoted = self._replies_map[reply.quote]
151151- body_preview = quoted.body[:200] + (
152152- "..." if len(quoted.body) > 200 else ""
152152+ parent_preview = None
153153+ if reply.parent and reply.parent in self._replies_map:
154154+ parent_post = self._replies_map[reply.parent]
155155+ body_preview = parent_post.body[:200] + (
156156+ "..." if len(parent_post.body) > 200 else ""
153157 )
154154- quote_text = f"{quoted.author.handle}: {body_preview}"
158158+ parent_preview = f"{parent_post.author.handle}: {body_preview}"
155159156160 await scroll.mount(
157161 Post(
···161165 author_did=reply.author.did,
162166 author_pds=reply.author.pds,
163167 record_uri=reply.uri,
164164- collection=lexicon.REPLY,
168168+ collection=lexicon.POST,
165169 attachments=reply.attachments,
166166- quote_text=quote_text,
170170+ parent_preview=parent_preview,
167171 ),
168172 before=self.query_one("#page-status-bottom"),
169173 )
170174171175 # Focus first reply
172176 replies = [
173173- post for post in self.query(Post) if post.collection == lexicon.REPLY
177177+ post for post in self.query(Post) if self._is_reply_widget(post)
174178 ]
175179 if replies:
176180 replies[0].focus()
···243247 if not session:
244248 return
245249246246- # If focused on a reply, quote it
247247- quote = None
250250+ # If focused on a reply, set it as the parent
251251+ parent = None
248252 focused = self.focused
249253 if (
250254 isinstance(focused, Post)
251251- and focused.collection == lexicon.REPLY
255255+ and self._is_reply_widget(focused)
252256 and focused.record_uri
253257 ):
254254- quote = self._replies_map.get(focused.record_uri)
258258+ parent = self._replies_map.get(focused.record_uri)
255259256260 self.app.push_screen(
257257- ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote)
261261+ ComposeReplyScreen(self.bbs, self.handle, self.thread, parent=parent)
258262 )
259263260264 def action_delete(self) -> None:
···278282 await delete_record(
279283 self.app.http_client,
280284 session,
281281- post.collection,
285285+ lexicon.POST,
282286 post.rkey,
283287 )
284288 except AuthError:
···288292 self.notify("Failed to delete.", severity="error")
289293 return
290294291291- if post.collection == lexicon.THREAD:
295295+ if post.record_uri == self.thread.uri:
292296 self.app.pop_screen()
293297 else:
294298 await post.remove()
+1-4
tui/util.py
···445566def require_session(screen) -> dict | None:
77- """Return the user session if logged in and not banned, else notify and return None."""
77+ """Return the user session if logged in, else notify and return None."""
88 session = screen.app.user_session
99 if not session:
1010 screen.notify("You must be logged in to do that.", severity="error")
1111- return None
1212- if screen.bbs.site.is_banned(session["did"]):
1313- screen.notify("You have been banned from this BBS.", severity="error")
1411 return None
1512 return session
1613
···116116 const [replies, setReplies] = useState<Reply[]>([]);
117117 const [loading, setLoading] = useState(true);
118118119119- // All replies we've ever seen — accumulates across page changes so quotes
120120- // and scroll targets always resolve, even for off-page replies.
119119+ // All replies we've ever seen — accumulates across page changes so parent
120120+ // previews and scroll targets always resolve, even for off-page replies.
121121 const [replyCache, setReplyCache] = useState<Record<string, Reply>>({});
122122123123 // Pending scroll target — set when navigating to a reply on another page.
···142142 // Fetch records from Slingshot.
143143 const records = await getRecordsBatch(slice);
144144145145- // Drop moderated content.
146146- const visible = records.filter((r) => {
147147- const { did } = parseAtUri(r.uri);
148148- return (
149149- !bbs.site.bannedDids.has(did) && !bbs.site.hiddenPosts.has(r.uri)
150150- );
151151- });
145145+ const visible = records;
152146153147 // Resolve author handles and build Reply objects.
154148 const dids = visible.map((r) => parseAtUri(r.uri).did);
···174168 const newCache: Record<string, Reply> = {};
175169 for (const item of items) newCache[item.uri] = item;
176170177177- // Fetch any quoted replies not already known
178178- const missingQuotes = items
179179- .filter((i) => i.quote && !newCache[i.quote!])
180180- .map((i) => i.quote!)
171171+ // Fetch any parent replies not already known
172172+ const missingParents = items
173173+ .filter((item) => item.parent && !newCache[item.parent!])
174174+ .map((item) => item.parent!)
181175 .filter((uri) => !replyCache[uri]);
182182- if (missingQuotes.length) {
183183- const quoteRefs = [...new Set(missingQuotes)].map((uri) =>
176176+ if (missingParents.length) {
177177+ const parentRefs = [...new Set(missingParents)].map((uri) =>
184178 parseAtUri(uri),
185179 );
186186- const quoteRecords = await getRecordsBatch(quoteRefs);
187187- const quoteDids = quoteRecords.map((r) => parseAtUri(r.uri).did);
188188- const quoteAuthors = await resolveIdentitiesBatch(quoteDids);
189189- for (const record of quoteRecords) {
190190- const reply = recordToReply(record, quoteAuthors);
180180+ const parentRecords = await getRecordsBatch(parentRefs);
181181+ const parentDids = parentRecords.map((record) => parseAtUri(record.uri).did);
182182+ const parentAuthors = await resolveIdentitiesBatch(parentDids);
183183+ for (const record of parentRecords) {
184184+ const reply = recordToReply(record, parentAuthors);
191185 if (reply) newCache[reply.uri] = reply;
192186 }
193187 }
+7-9
web/src/lexicons/index.ts
···11-export * as XyzAtboardsBan from "./types/xyz/atboards/ban.js";
22-export * as XyzAtboardsBoard from "./types/xyz/atboards/board.js";
33-export * as XyzAtboardsHide from "./types/xyz/atboards/hide.js";
44-export * as XyzAtboardsNews from "./types/xyz/atboards/news.js";
55-export * as XyzAtboardsPin from "./types/xyz/atboards/pin.js";
66-export * as XyzAtboardsProfile from "./types/xyz/atboards/profile.js";
77-export * as XyzAtboardsReply from "./types/xyz/atboards/reply.js";
88-export * as XyzAtboardsSite from "./types/xyz/atboards/site.js";
99-export * as XyzAtboardsThread from "./types/xyz/atboards/thread.js";
11+export * as XyzAtbbsBan from "./types/xyz/atbbs/ban.js";
22+export * as XyzAtbbsBoard from "./types/xyz/atbbs/board.js";
33+export * as XyzAtbbsHide from "./types/xyz/atbbs/hide.js";
44+export * as XyzAtbbsPin from "./types/xyz/atbbs/pin.js";
55+export * as XyzAtbbsPost from "./types/xyz/atbbs/post.js";
66+export * as XyzAtbbsProfile from "./types/xyz/atbbs/profile.js";
77+export * as XyzAtbbsSite from "./types/xyz/atbbs/site.js";