From daa7cd201b8f8d1dcf04cc9c45d6c1e874fadd5b Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 16 Apr 2026 12:22:52 -0400 Subject: [PATCH] migrate lexicons! --- README.md | 6 +- core/constellation.py | 38 +-- core/filters.py | 8 +- core/lexicon.py | 20 +- core/limits.py | 9 +- core/models.py | 57 +--- core/records.py | 289 +++++++----------- core/resolver.py | 93 +++--- ...z.atboards.ban.json => xyz.atbbs.ban.json} | 2 +- ...boards.board.json => xyz.atbbs.board.json} | 2 +- ...atboards.hide.json => xyz.atbbs.hide.json} | 2 +- ...z.atboards.pin.json => xyz.atbbs.pin.json} | 4 +- ...boards.thread.json => xyz.atbbs.post.json} | 23 +- ...ds.profile.json => xyz.atbbs.profile.json} | 2 +- ...atboards.site.json => xyz.atbbs.site.json} | 14 +- lexicons/xyz.atboards.news.json | 60 ---- lexicons/xyz.atboards.reply.json | 63 ---- telnet/server.py | 12 +- tui/screens/activity.py | 28 +- tui/screens/board.py | 2 +- tui/screens/compose/news.py | 16 +- tui/screens/compose/reply.py | 57 ++-- tui/screens/compose/thread.py | 16 +- tui/screens/home.py | 6 - tui/screens/news.py | 9 +- tui/screens/site.py | 2 +- tui/screens/sysop/delete.py | 13 +- tui/screens/sysop/edit.py | 7 +- tui/screens/thread.py | 76 ++--- tui/util.py | 5 +- tui/widgets/post.py | 10 +- web/docker-entrypoint.sh | 2 +- web/src/components/ActivityList.tsx | 4 +- web/src/components/form/ComposeForm.tsx | 16 +- web/src/components/post/NewsCard.tsx | 4 +- web/src/components/post/PostActions.tsx | 16 +- web/src/components/post/ReplyCard.tsx | 26 +- web/src/hooks/useThreadReplies.ts | 34 +-- web/src/lexicons/index.ts | 16 +- .../types/xyz/{atboards => atbbs}/ban.ts | 4 +- .../types/xyz/{atboards => atbbs}/board.ts | 4 +- .../types/xyz/{atboards => atbbs}/hide.ts | 4 +- .../types/xyz/{atboards => atbbs}/pin.ts | 6 +- .../xyz/{atboards/thread.ts => atbbs/post.ts} | 23 +- .../types/xyz/{atboards => atbbs}/profile.ts | 4 +- .../types/xyz/{atboards => atbbs}/site.ts | 12 +- web/src/lexicons/types/xyz/atboards/news.ts | 68 ----- web/src/lexicons/types/xyz/atboards/reply.ts | 64 ---- web/src/lib/activity.ts | 50 +-- web/src/lib/atproto.ts | 4 - web/src/lib/bbs.ts | 95 +++--- web/src/lib/deletebbs.ts | 38 ++- web/src/lib/lexicon.ts | 16 +- web/src/lib/limits.ts | 9 +- web/src/lib/mythreads.ts | 35 ++- web/src/lib/pins.ts | 12 +- web/src/lib/profile.ts | 10 +- web/src/lib/replies.ts | 10 +- web/src/lib/util.ts | 6 +- web/src/lib/writes.ts | 115 +++---- web/src/pages/BBS.tsx | 48 ++- web/src/pages/Board.tsx | 21 +- web/src/pages/Dashboard.tsx | 2 +- web/src/pages/News.tsx | 6 +- web/src/pages/SysopCreate.tsx | 5 +- web/src/pages/SysopEdit.tsx | 5 +- web/src/pages/SysopModerate.tsx | 2 +- web/src/pages/Thread.tsx | 53 ++-- web/src/router/loaders/board.ts | 21 +- web/src/router/loaders/sysop.ts | 33 +- web/src/router/loaders/thread.ts | 30 +- web/tsconfig.tsbuildinfo | 2 +- web/vite.config.ts | 16 +- 73 files changed, 722 insertions(+), 1180 deletions(-) rename lexicons/{xyz.atboards.ban.json => xyz.atbbs.ban.json} (93%) rename lexicons/{xyz.atboards.board.json => xyz.atbbs.board.json} (95%) rename lexicons/{xyz.atboards.hide.json => xyz.atbbs.hide.json} (93%) rename lexicons/{xyz.atboards.pin.json => xyz.atbbs.pin.json} (89%) rename lexicons/{xyz.atboards.thread.json => xyz.atbbs.post.json} (82%) rename lexicons/{xyz.atboards.profile.json => xyz.atbbs.profile.json} (94%) rename lexicons/{xyz.atboards.site.json => xyz.atbbs.site.json} (76%) delete mode 100644 lexicons/xyz.atboards.news.json delete mode 100644 lexicons/xyz.atboards.reply.json rename web/src/lexicons/types/xyz/{atboards => atbbs}/ban.ts (87%) rename web/src/lexicons/types/xyz/{atboards => atbbs}/board.ts (91%) rename web/src/lexicons/types/xyz/{atboards => atbbs}/hide.ts (87%) rename web/src/lexicons/types/xyz/{atboards => atbbs}/pin.ts (82%) rename web/src/lexicons/types/xyz/{atboards/thread.ts => atbbs/post.ts} (73%) rename web/src/lexicons/types/xyz/{atboards => atbbs}/profile.ts (92%) rename web/src/lexicons/types/xyz/{atboards => atbbs}/site.ts (75%) delete mode 100644 web/src/lexicons/types/xyz/atboards/news.ts delete mode 100644 web/src/lexicons/types/xyz/atboards/reply.ts diff --git a/README.md b/README.md index 954401a..a55d937 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ just docker # build docker image atbbs has no backend database for content. All BBS data lives in atproto repos: -- **Sysop records**: `xyz.atboards.site`, `xyz.atboards.board`, `xyz.atboards.news` -- **Moderation records**: `xyz.atboards.ban`, `xyz.atboards.hide` -- **User records**: `xyz.atboards.thread`, `xyz.atboards.reply` +- **Sysop records**: `xyz.atbbs.site`, `xyz.atbbs.board` +- **Moderation records**: `xyz.atbbs.ban`, `xyz.atbbs.hide` +- **User records**: `xyz.atbbs.post`, `xyz.atbbs.pin`, `xyz.atbbs.profile` The web app and TUI query existing network infrastructure: diff --git a/core/constellation.py b/core/constellation.py index 3255e94..5e5617c 100644 --- a/core/constellation.py +++ b/core/constellation.py @@ -30,40 +30,24 @@ async def get_backlinks( return BacklinksResponse( total=data["total"], records=[ - BacklinkRef(did=r["did"], collection=r["collection"], rkey=r["rkey"]) - for r in data["records"] + BacklinkRef(did=entry["did"], collection=entry["collection"], rkey=entry["rkey"]) + for entry in data["records"] ], cursor=data.get("cursor"), ) -async def get_threads( +async def get_root_posts( client: httpx.AsyncClient, - board_uri: str, + scope_uri: str, limit: int = 50, cursor: str | None = None, ) -> BacklinksResponse: - """Get threads pointing at a board.""" + """Get root posts (threads or news) pointing at a scope (board or site).""" return await get_backlinks( client, - subject=board_uri, - source=f"{lexicon.THREAD}:board", - limit=limit, - cursor=cursor, - ) - - -async def get_news( - client: httpx.AsyncClient, - site_uri: str, - limit: int = 50, - cursor: str | None = None, -) -> BacklinksResponse: - """Get news posts pointing at a site.""" - return await get_backlinks( - client, - subject=site_uri, - source=f"{lexicon.NEWS}:site", + subject=scope_uri, + source=f"{lexicon.POST}:scope", limit=limit, cursor=cursor, ) @@ -71,15 +55,15 @@ async def get_news( async def get_replies( client: httpx.AsyncClient, - thread_uri: str, + root_uri: str, limit: int = 50, cursor: str | None = None, ) -> BacklinksResponse: - """Get replies pointing at a thread.""" + """Get replies pointing at a root post.""" return await get_backlinks( client, - subject=thread_uri, - source=f"{lexicon.REPLY}:subject", + subject=root_uri, + source=f"{lexicon.POST}:root", limit=limit, cursor=cursor, ) diff --git a/core/filters.py b/core/filters.py index d6dbc31..04f97d9 100644 --- a/core/filters.py +++ b/core/filters.py @@ -1,4 +1,3 @@ -from core import lexicon from core.models import AtUri, Record @@ -7,7 +6,8 @@ def filter_moderated( ) -> list[Record]: """Remove records from banned users or hidden by the sysop.""" return [ - r - for r in records - if AtUri.parse(r.uri).did not in banned_dids and r.uri not in hidden_posts + record + for record in records + if AtUri.parse(record.uri).did not in banned_dids + and record.uri not in hidden_posts ] diff --git a/core/lexicon.py b/core/lexicon.py index ac0e2d0..56269b8 100644 --- a/core/lexicon.py +++ b/core/lexicon.py @@ -1,13 +1,11 @@ -"""AT Protocol collection names for atboards lexicons.""" +"""AT Protocol collection names for atbbs lexicons.""" -SITE = "xyz.atboards.site" -BOARD = "xyz.atboards.board" -NEWS = "xyz.atboards.news" -THREAD = "xyz.atboards.thread" -REPLY = "xyz.atboards.reply" -BAN = "xyz.atboards.ban" -HIDE = "xyz.atboards.hide" -PIN = "xyz.atboards.pin" -PROFILE = "xyz.atboards.profile" +SITE = "xyz.atbbs.site" +BOARD = "xyz.atbbs.board" +POST = "xyz.atbbs.post" +BAN = "xyz.atbbs.ban" +HIDE = "xyz.atbbs.hide" +PIN = "xyz.atbbs.pin" +PROFILE = "xyz.atbbs.profile" -OAUTH_SCOPE = f"atproto blob:*/* repo:{SITE} repo:{BOARD} repo:{NEWS} repo:{THREAD} repo:{REPLY} repo:{BAN} repo:{HIDE} repo:{PIN} repo:{PROFILE}" +OAUTH_SCOPE = f"atproto blob:*/* repo:{SITE} repo:{BOARD} repo:{POST} repo:{BAN} repo:{HIDE} repo:{PIN} repo:{PROFILE}" diff --git a/core/limits.py b/core/limits.py index d18ddcd..b6da931 100644 --- a/core/limits.py +++ b/core/limits.py @@ -1,15 +1,12 @@ -"""Field length limits from the atboards lexicons.""" +"""Field length limits from the atbbs lexicons.""" SITE_NAME = 100 SITE_DESCRIPTION = 1000 SITE_INTRO = 5000 BOARD_NAME = 100 BOARD_DESCRIPTION = 500 -THREAD_TITLE = 300 -THREAD_BODY = 10000 -NEWS_TITLE = 300 -NEWS_BODY = 10000 -REPLY_BODY = 10000 +POST_TITLE = 300 +POST_BODY = 10000 ATTACHMENT_NAME = 256 MAX_ATTACHMENTS = 10 PROFILE_NAME = 100 diff --git a/core/models.py b/core/models.py index 7ea2482..df1c8dd 100644 --- a/core/models.py +++ b/core/models.py @@ -96,7 +96,7 @@ class Record: @dataclass class Board: - """xyz.atboards.board — a subforum defined by the sysop.""" + """xyz.atbbs.board — a subforum defined by the sysop.""" slug: str name: str @@ -106,60 +106,35 @@ class Board: @dataclass -class News: - """xyz.atboards.news — a sysop announcement.""" +class Post: + """xyz.atbbs.post — a thread, reply, or news item.""" - tid: str - site_uri: str - title: str + uri: str + scope: str body: str created_at: str + author: MiniDoc + title: str | None = None + root: str | None = None + parent: str | None = None + updated_at: str | None = None attachments: list[dict] | None = None + @property + def is_root(self) -> bool: + return self.root is None + @dataclass class Site: - """xyz.atboards.site/self — the BBS front door.""" + """xyz.atbbs.site/self — the BBS front door.""" name: str description: str intro: str boards: list[Board] - banned_dids: set[str] - hidden_posts: set[str] - created_at: str - updated_at: str | None = None - - def is_banned(self, did: str) -> bool: - return did in self.banned_dids - - -@dataclass -class Thread: - """xyz.atboards.thread — a user's thread on a board.""" - - uri: str - board_uri: str - title: str - body: str created_at: str - author: MiniDoc updated_at: str | None = None - attachments: list[dict] | None = None - - -@dataclass -class Reply: - """xyz.atboards.reply — a user's reply to a thread.""" - - uri: str - subject_uri: str - body: str - created_at: str - author: MiniDoc - updated_at: str | None = None - attachments: list[dict] | None = None - quote: str | None = None @dataclass @@ -168,4 +143,4 @@ class BBS: identity: MiniDoc site: Site - news: list[News] + news: list[Post] diff --git a/core/records.py b/core/records.py index d8ffc23..8c1a18b 100644 --- a/core/records.py +++ b/core/records.py @@ -8,63 +8,56 @@ from dataclasses import dataclass import httpx from core import lexicon -from core.constellation import get_replies, get_threads +from core.constellation import get_replies, get_root_posts from core.filters import filter_moderated -from core.models import AtUri, AuthError, BBS, Board, MiniDoc, Record, Reply, Thread +from core.models import AtUri, AuthError, BBS, Board, MiniDoc, Post, Record from core.slingshot import get_records_batch, resolve_identities_batch from core.util import now_iso -def thread_from_record(record: Record, author: MiniDoc) -> Thread: - """Construct a Thread from a raw Record and resolved author.""" - return Thread( +def post_from_record(record: Record, author: MiniDoc) -> Post: + """Construct a Post from a raw Record and resolved author.""" + return Post( uri=record.uri, - board_uri=record.value["board"], - title=record.value["title"], - body=record.value["body"], - created_at=record.value["createdAt"], + scope=record.value.get("scope", ""), + body=record.value.get("body", ""), + created_at=record.value.get("createdAt", ""), author=author, + title=record.value.get("title"), + root=record.value.get("root"), + parent=record.value.get("parent"), updated_at=record.value.get("updatedAt"), attachments=record.value.get("attachments"), ) -def reply_from_record(record: Record, author: MiniDoc) -> Reply: - """Construct a Reply from a raw Record and resolved author.""" - return Reply( - uri=record.uri, - subject_uri=record.value["subject"], - body=record.value["body"], - created_at=record.value["createdAt"], - author=author, - updated_at=record.value.get("updatedAt"), - attachments=record.value.get("attachments"), - quote=record.value.get("quote"), - ) - - async def hydrate_threads( client: httpx.AsyncClient, bbs: BBS, board: Board, + banned_dids: set[str] | None = None, + hidden_posts: set[str] | None = None, cursor: str | None = None, -) -> tuple[list[Thread], str | None]: - """Fetch and hydrate threads for a board.""" +) -> tuple[list[Post], str | None]: + """Fetch and hydrate root posts (threads) for a board.""" board_uri = str(AtUri(bbs.identity.did, lexicon.BOARD, board.slug)) - backlinks = await get_threads(client, board_uri, cursor=cursor) + backlinks = await get_root_posts(client, board_uri, cursor=cursor) records = await get_records_batch(client, backlinks.records) - records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) + if banned_dids or hidden_posts: + records = filter_moderated(records, banned_dids or set(), hidden_posts or set()) - parsed = {r.uri: AtUri.parse(r.uri) for r in records} - dids = [p.did for p in parsed.values()] + parsed = {record.uri: AtUri.parse(record.uri) for record in records} + dids = [parsed_uri.did for parsed_uri in parsed.values()] authors = await resolve_identities_batch(client, dids) threads = [ - thread_from_record(r, authors[parsed[r.uri].did]) - for r in records - if parsed[r.uri].did in authors + post_from_record(record, authors[parsed[record.uri].did]) + for record in records + if parsed[record.uri].did in authors ] - threads.sort(key=lambda t: t.created_at, reverse=True) + # Filter to root posts only (no root field = thread, not a reply) + threads = [thread for thread in threads if thread.is_root] + threads.sort(key=lambda thread: thread.created_at, reverse=True) return threads, backlinks.cursor @@ -72,7 +65,7 @@ async def hydrate_threads( class RepliesPage: """A page of hydrated replies with pagination info.""" - replies: list[Reply] + replies: list[Post] page: int total_pages: int total_replies: int @@ -81,7 +74,9 @@ class RepliesPage: async def hydrate_replies( client: httpx.AsyncClient, bbs: BBS, - thread_uri: str, + root_uri: str, + banned_dids: set[str] | None = None, + hidden_posts: set[str] | None = None, page: int = 1, page_size: int = 10, focus_reply: str | None = None, @@ -92,7 +87,7 @@ async def hydrate_replies( containing that reply. """ # Fetch all refs (cheap — just did/collection/rkey) - backlinks = await get_replies(client, thread_uri, limit=1000) + backlinks = await get_replies(client, root_uri, limit=1000) all_refs = list(reversed(backlinks.records)) # oldest first total = len(all_refs) @@ -118,18 +113,19 @@ async def hydrate_replies( # Hydrate only this page records = await get_records_batch(client, page_refs) - records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) + if banned_dids or hidden_posts: + records = filter_moderated(records, banned_dids or set(), hidden_posts or set()) - parsed = {r.uri: AtUri.parse(r.uri) for r in records} - dids = [p.did for p in parsed.values()] + parsed = {record.uri: AtUri.parse(record.uri) for record in records} + dids = [parsed_uri.did for parsed_uri in parsed.values()] authors = await resolve_identities_batch(client, dids) replies = [ - reply_from_record(r, authors[parsed[r.uri].did]) - for r in records - if parsed[r.uri].did in authors + post_from_record(record, authors[parsed[record.uri].did]) + for record in records + if parsed[record.uri].did in authors ] - replies.sort(key=lambda t: t.created_at) + replies.sort(key=lambda reply: reply.created_at) return RepliesPage( replies=replies, page=page, total_pages=total_pages, total_replies=total ) @@ -274,65 +270,39 @@ async def upload_blob( return resp.json()["blob"] -async def create_thread_record( - client: httpx.AsyncClient, - session: dict, - board_uri: str, - title: str, - body: str, - attachments: list[dict] | None = None, - session_updater=None, -) -> httpx.Response: - """Create a thread record in the user's repo.""" - record = { - "$type": lexicon.THREAD, - "board": board_uri, - "title": title, - "body": body, - "createdAt": now_iso(), - } - if attachments: - record["attachments"] = attachments - return await pds_post( - client, - session, - "com.atproto.repo.createRecord", - { - "repo": session["did"], - "collection": lexicon.THREAD, - "record": record, - }, - session_updater, - ) - - -async def create_reply_record( +async def create_post_record( client: httpx.AsyncClient, session: dict, - thread_uri: str, + scope: str, body: str, + title: str | None = None, + root: str | None = None, + parent: str | None = None, attachments: list[dict] | None = None, - quote: str | None = None, session_updater=None, ) -> httpx.Response: - """Create a reply record in the user's repo.""" - record = { - "$type": lexicon.REPLY, - "subject": thread_uri, + """Create a post record in the user's repo.""" + record: dict = { + "$type": lexicon.POST, + "scope": scope, "body": body, "createdAt": now_iso(), } + if title: + record["title"] = title + if root: + record["root"] = root + if parent: + record["parent"] = parent if attachments: record["attachments"] = attachments - if quote: - record["quote"] = quote return await pds_post( client, session, "com.atproto.repo.createRecord", { "repo": session["did"], - "collection": lexicon.REPLY, + "collection": lexicon.POST, "record": record, }, session_updater, @@ -486,78 +456,43 @@ async def put_site_record( ) -async def create_news_record( - client: httpx.AsyncClient, - session: dict, - site_uri: str, - title: str, - body: str, - attachments: list[dict] | None = None, - session_updater=None, -) -> httpx.Response: - """Create a news record in the user's repo.""" - record = { - "$type": lexicon.NEWS, - "site": site_uri, - "title": title, - "body": body, - "createdAt": now_iso(), - } - if attachments: - record["attachments"] = attachments - return await pds_post( - client, - session, - "com.atproto.repo.createRecord", - { - "repo": session["did"], - "collection": lexicon.NEWS, - "record": record, - }, - session_updater, - ) - - async def fetch_inbox( client: httpx.AsyncClient, did: str, pds_url: str, max_items: int = 50, ) -> list[dict]: - """Fetch inbox: replies to user's threads + quotes of user's replies.""" + """Fetch inbox: replies to user's root posts and replies to user's replies.""" import asyncio from core.constellation import get_backlinks - SCAN_LIMIT = 20 # how many threads/replies to scan + SCAN_LIMIT = 20 # how many posts to scan BACKLINK_LIMIT = 25 # backlinks per record MAX_CONCURRENT = 10 # concurrent API calls sem = asyncio.Semaphore(MAX_CONCURRENT) - # Fetch thread and reply lists concurrently - async def list_records(collection): - try: - resp = await client.get( - f"{pds_url}/xrpc/com.atproto.repo.listRecords", - params={"repo": did, "collection": collection, "limit": SCAN_LIMIT}, - ) - resp.raise_for_status() - return resp.json().get("records", []) - except Exception: - return [] - - thread_records, reply_records = await asyncio.gather( - list_records(lexicon.THREAD), - list_records(lexicon.REPLY), - ) + # Fetch user's posts + try: + resp = await client.get( + f"{pds_url}/xrpc/com.atproto.repo.listRecords", + params={"repo": did, "collection": lexicon.POST, "limit": SCAN_LIMIT}, + ) + resp.raise_for_status() + all_posts = resp.json().get("records", []) + except Exception: + all_posts = [] + + root_posts = [post for post in all_posts if "root" not in post["value"]] + reply_posts = [post for post in all_posts if "root" in post["value"]] - # Batch-resolve BBS handles for all threads at once + # Batch-resolve BBS handles for all root posts at once bbs_dids = set() - for tr in thread_records: - board_uri = tr["value"].get("board", "") - if board_uri: - bbs_dids.add(AtUri.parse(board_uri).did) + for root_post in root_posts: + scope = root_post["value"].get("scope", "") + if scope: + bbs_dids.add(AtUri.parse(scope).did) try: bbs_authors = ( await resolve_identities_batch(client, list(bbs_dids)) if bbs_dids else {} @@ -565,40 +500,40 @@ async def fetch_inbox( except Exception: bbs_authors = {} - # 1. Fetch replies to user's threads (concurrent) - async def fetch_thread_replies(tr): + # 1. Fetch replies to user's root posts (concurrent) + async def fetch_post_replies(root_post): async with sem: - thread_uri = tr["uri"] - thread_title = tr["value"].get("title", "") - board_uri = tr["value"].get("board", "") - bbs_did = AtUri.parse(board_uri).did if board_uri else did + post_uri = root_post["uri"] + post_title = root_post["value"].get("title", "") + scope = root_post["value"].get("scope", "") + bbs_did = AtUri.parse(scope).did if scope else did bbs_handle = bbs_authors[bbs_did].handle if bbs_did in bbs_authors else "" try: - backlinks = await get_replies(client, thread_uri, limit=BACKLINK_LIMIT) + backlinks = await get_replies(client, post_uri, limit=BACKLINK_LIMIT) records = await get_records_batch(client, backlinks.records) - parsed = {r.uri: AtUri.parse(r.uri) for r in records} - records = [r for r in records if parsed[r.uri].did != did] + parsed = {record.uri: AtUri.parse(record.uri) for record in records} + records = [record for record in records if parsed[record.uri].did != did] if not records: return [] - dids = [parsed[r.uri].did for r in records] + dids = [parsed[record.uri].did for record in records] authors = await resolve_identities_batch(client, dids) items = [] - for r in records: - author_did = parsed[r.uri].did + for record in records: + author_did = parsed[record.uri].did if author_did not in authors: continue items.append( { "type": "reply", - "reply_uri": r.uri, - "thread_title": thread_title, - "thread_uri": thread_uri, + "reply_uri": record.uri, + "thread_title": post_title, + "thread_uri": post_uri, "handle": authors[author_did].handle, - "body": r.value.get("body", "")[:200], - "created_at": r.value.get("createdAt", ""), + "body": record.value.get("body", "")[:200], + "created_at": record.value.get("createdAt", ""), "bbs_handle": bbs_handle, } ) @@ -606,44 +541,44 @@ async def fetch_inbox( except Exception: return [] - # 2. Fetch quotes of user's replies (concurrent) - async def fetch_reply_quotes(rr): + # 2. Fetch replies that reference user's replies as parent (concurrent) + async def fetch_child_replies(reply_post): async with sem: - reply_uri = rr["uri"] - thread_uri = rr["value"].get("subject", "") + reply_uri = reply_post["uri"] + root_uri = reply_post["value"].get("root", "") try: backlinks = await get_backlinks( client, subject=reply_uri, - source=f"{lexicon.REPLY}:quote", + source=f"{lexicon.POST}:parent", limit=BACKLINK_LIMIT, ) if not backlinks.records: return [] records = await get_records_batch(client, backlinks.records) - parsed = {r.uri: AtUri.parse(r.uri) for r in records} - records = [r for r in records if parsed[r.uri].did != did] + parsed = {record.uri: AtUri.parse(record.uri) for record in records} + records = [record for record in records if parsed[record.uri].did != did] if not records: return [] - dids = [parsed[r.uri].did for r in records] + dids = [parsed[record.uri].did for record in records] authors = await resolve_identities_batch(client, dids) items = [] - for r in records: - author_did = parsed[r.uri].did + for record in records: + author_did = parsed[record.uri].did if author_did not in authors: continue items.append( { - "type": "quote", - "reply_uri": r.uri, + "type": "parent_reply", + "reply_uri": record.uri, "thread_title": "", - "thread_uri": thread_uri, + "thread_uri": root_uri, "handle": authors[author_did].handle, - "body": r.value.get("body", "")[:200], - "created_at": r.value.get("createdAt", ""), + "body": record.value.get("body", "")[:200], + "created_at": record.value.get("createdAt", ""), "bbs_handle": "", } ) @@ -653,24 +588,24 @@ async def fetch_inbox( # Run all lookups concurrently results = await asyncio.gather( - *[fetch_thread_replies(tr) for tr in thread_records], - *[fetch_reply_quotes(rr) for rr in reply_records], + *[fetch_post_replies(root_post) for root_post in root_posts], + *[fetch_child_replies(reply_post) for reply_post in reply_posts], ) all_items = [] for items in results: all_items.extend(items) - # Deduplicate and prefer quotes if same record appears in both + # Deduplicate and prefer parent-reply type if same record appears in both seen = {} for item in all_items: key = item["handle"] + item["body"] + item["created_at"] if key in seen: - if item["type"] == "quote": + if item["type"] == "parent_reply": seen[key] = item else: seen[key] = item deduped = list(seen.values()) - deduped.sort(key=lambda a: a["created_at"], reverse=True) + deduped.sort(key=lambda item: item["created_at"], reverse=True) return deduped diff --git a/core/resolver.py b/core/resolver.py index 7e8e7c8..0943324 100644 --- a/core/resolver.py +++ b/core/resolver.py @@ -6,7 +6,7 @@ from core.models import ( AtUri, BBS, Board, - News, + Post, Site, BBSNotFoundError, NoBBSError, @@ -14,8 +14,8 @@ from core.models import ( ) from core import lexicon from core.cache import TTLCache -from core.constellation import get_news -from core.records import list_pds_records +from core.constellation import get_root_posts +from core.records import list_pds_records, post_from_record from core.slingshot import get_record, get_records_batch, resolve_identity _bbs_cache = TTLCache(ttl_seconds=300) # 5 minutes @@ -50,74 +50,57 @@ async def _resolve_bbs(client: httpx.AsyncClient, handle: str) -> BBS: except httpx.TransportError: raise NetworkError("Could not reach the network.") - sv = site_record.value + site_value = site_record.value site_uri = str(AtUri(identity.did, lexicon.SITE, "self")) - # Fetch boards, news, bans, and hidden posts concurrently - board_slugs = sv["boards"] - board_tasks = [ - get_record(client, identity.did, lexicon.BOARD, slug) for slug in board_slugs - ] - news_task = get_news(client, site_uri) - ban_task = list_pds_records(client, identity.pds, identity.did, lexicon.BAN) - hidden_task = list_pds_records(client, identity.pds, identity.did, lexicon.HIDE) + # Fetch boards and news concurrently + board_uris = site_value["boards"] + board_tasks = [] + for uri in board_uris: + parsed = AtUri.parse(uri) + board_tasks.append(get_record(client, parsed.did, parsed.collection, parsed.rkey)) + news_task = get_root_posts(client, site_uri) results = await asyncio.gather( - *board_tasks, news_task, ban_task, hidden_task, return_exceptions=True + *board_tasks, news_task, return_exceptions=True ) - board_records = results[: len(board_slugs)] - news_result = results[len(board_slugs)] - ban_result = results[len(board_slugs) + 1] - hidden_result = results[len(board_slugs) + 2] - - boards = [ - Board( - slug=slug, - name=r.value["name"], - description=r.value["description"], - created_at=r.value["createdAt"], - updated_at=r.value.get("updatedAt"), + board_records = results[: len(board_uris)] + news_result = results[len(board_uris)] + + boards = [] + for uri, record in zip(board_uris, board_records): + if isinstance(record, BaseException): + continue + parsed = AtUri.parse(uri) + boards.append( + Board( + slug=parsed.rkey, + name=record.value["name"], + description=record.value["description"], + created_at=record.value["createdAt"], + updated_at=record.value.get("updatedAt"), + ) ) - for slug, r in zip(board_slugs, board_records) - if not isinstance(r, BaseException) - ] - # Hydrate news records (only from the sysop's repo) + # Hydrate news posts (only from the sysop's repo) if isinstance(news_result, BaseException): news_records = [] else: - sysop_news = [r for r in news_result.records if r.did == identity.did] + sysop_news = [ref for ref in news_result.records if ref.did == identity.did] news_records = await get_records_batch(client, sysop_news) news = [ - News( - tid=AtUri.parse(r.uri).rkey, - site_uri=r.value["site"], - title=r.value["title"], - body=r.value["body"], - created_at=r.value["createdAt"], - attachments=r.value.get("attachments"), - ) - for r in news_records + post_from_record(record, identity) + for record in news_records ] - news.sort(key=lambda n: n.created_at, reverse=True) - - # Build ban/hidden sets from standalone records - banned_dids: set[str] = set() - if not isinstance(ban_result, BaseException): - banned_dids = {r["value"]["did"] for r in ban_result} - hidden_posts: set[str] = set() - if not isinstance(hidden_result, BaseException): - hidden_posts = {r["value"]["uri"] for r in hidden_result} + news.sort(key=lambda post: post.created_at, reverse=True) site = Site( - name=sv["name"], - description=sv["description"], - intro=sv["intro"], + name=site_value["name"], + description=site_value["description"], + intro=site_value["intro"], boards=boards, - banned_dids=banned_dids, - hidden_posts=hidden_posts, - created_at=sv.get("createdAt", ""), - updated_at=sv.get("updatedAt"), + created_at=site_value.get("createdAt", ""), + updated_at=site_value.get("updatedAt"), ) return BBS(identity=identity, site=site, news=news) diff --git a/lexicons/xyz.atboards.ban.json b/lexicons/xyz.atbbs.ban.json similarity index 93% rename from lexicons/xyz.atboards.ban.json rename to lexicons/xyz.atbbs.ban.json index e23e2a0..47d7960 100644 --- a/lexicons/xyz.atboards.ban.json +++ b/lexicons/xyz.atbbs.ban.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "xyz.atboards.ban", + "id": "xyz.atbbs.ban", "defs": { "main": { "type": "record", diff --git a/lexicons/xyz.atboards.board.json b/lexicons/xyz.atbbs.board.json similarity index 95% rename from lexicons/xyz.atboards.board.json rename to lexicons/xyz.atbbs.board.json index 1fe8504..7514c08 100644 --- a/lexicons/xyz.atboards.board.json +++ b/lexicons/xyz.atbbs.board.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "xyz.atboards.board", + "id": "xyz.atbbs.board", "defs": { "main": { "type": "record", diff --git a/lexicons/xyz.atboards.hide.json b/lexicons/xyz.atbbs.hide.json similarity index 93% rename from lexicons/xyz.atboards.hide.json rename to lexicons/xyz.atbbs.hide.json index 65f7755..a47bfd8 100644 --- a/lexicons/xyz.atboards.hide.json +++ b/lexicons/xyz.atbbs.hide.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "xyz.atboards.hide", + "id": "xyz.atbbs.hide", "defs": { "main": { "type": "record", diff --git a/lexicons/xyz.atboards.pin.json b/lexicons/xyz.atbbs.pin.json similarity index 89% rename from lexicons/xyz.atboards.pin.json rename to lexicons/xyz.atbbs.pin.json index 5284063..43d7e19 100644 --- a/lexicons/xyz.atboards.pin.json +++ b/lexicons/xyz.atbbs.pin.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "xyz.atboards.pin", + "id": "xyz.atbbs.pin", "defs": { "main": { "type": "record", - "key": "tid", + "key": "any", "record": { "type": "object", "required": ["did", "createdAt"], diff --git a/lexicons/xyz.atboards.thread.json b/lexicons/xyz.atbbs.post.json similarity index 82% rename from lexicons/xyz.atboards.thread.json rename to lexicons/xyz.atbbs.post.json index 06a553b..1b0e3f3 100644 --- a/lexicons/xyz.atboards.thread.json +++ b/lexicons/xyz.atbbs.post.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "xyz.atboards.thread", + "id": "xyz.atbbs.post", "defs": { "main": { "type": "record", @@ -8,13 +8,12 @@ "record": { "type": "object", "required": [ - "board", - "title", + "scope", "body", "createdAt" ], "properties": { - "board": { + "scope": { "type": "string", "format": "at-uri" }, @@ -26,10 +25,22 @@ "type": "string", "maxLength": 10000 }, + "root": { + "type": "string", + "format": "at-uri" + }, + "parent": { + "type": "string", + "format": "at-uri" + }, "createdAt": { "type": "string", "format": "datetime" }, + "updatedAt": { + "type": "string", + "format": "datetime" + }, "attachments": { "type": "array", "maxLength": 10, @@ -37,10 +48,6 @@ "type": "ref", "ref": "#attachment" } - }, - "updatedAt": { - "type": "string", - "format": "datetime" } } } diff --git a/lexicons/xyz.atboards.profile.json b/lexicons/xyz.atbbs.profile.json similarity index 94% rename from lexicons/xyz.atboards.profile.json rename to lexicons/xyz.atbbs.profile.json index 67533f1..2d66cbe 100644 --- a/lexicons/xyz.atboards.profile.json +++ b/lexicons/xyz.atbbs.profile.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "xyz.atboards.profile", + "id": "xyz.atbbs.profile", "defs": { "main": { "type": "record", diff --git a/lexicons/xyz.atboards.site.json b/lexicons/xyz.atbbs.site.json similarity index 76% rename from lexicons/xyz.atboards.site.json rename to lexicons/xyz.atbbs.site.json index fad1f90..cd3bda4 100644 --- a/lexicons/xyz.atboards.site.json +++ b/lexicons/xyz.atbbs.site.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "xyz.atboards.site", + "id": "xyz.atbbs.site", "defs": { "main": { "type": "record", @@ -28,18 +28,6 @@ "maxLength": 5000 }, "boards": { - "type": "array", - "items": { - "type": "string" - } - }, - "bannedDids": { - "type": "array", - "items": { - "type": "string" - } - }, - "hiddenPosts": { "type": "array", "items": { "type": "string", diff --git a/lexicons/xyz.atboards.news.json b/lexicons/xyz.atboards.news.json deleted file mode 100644 index 086a149..0000000 --- a/lexicons/xyz.atboards.news.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "lexicon": 1, - "id": "xyz.atboards.news", - "defs": { - "main": { - "type": "record", - "key": "tid", - "record": { - "type": "object", - "required": [ - "site", - "title", - "body", - "createdAt" - ], - "properties": { - "site": { - "type": "string", - "format": "at-uri" - }, - "title": { - "type": "string", - "maxLength": 300 - }, - "body": { - "type": "string", - "maxLength": 10000 - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "attachments": { - "type": "array", - "maxLength": 10, - "items": { - "type": "ref", - "ref": "#attachment" - } - } - } - } - }, - "attachment": { - "type": "object", - "required": ["file", "name"], - "properties": { - "file": { - "type": "blob", - "accept": ["*/*"], - "maxSize": 1000000 - }, - "name": { - "type": "string", - "maxLength": 256 - } - } - } - } -} diff --git a/lexicons/xyz.atboards.reply.json b/lexicons/xyz.atboards.reply.json deleted file mode 100644 index 4765dea..0000000 --- a/lexicons/xyz.atboards.reply.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "lexicon": 1, - "id": "xyz.atboards.reply", - "defs": { - "main": { - "type": "record", - "key": "tid", - "record": { - "type": "object", - "required": [ - "subject", - "body", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri" - }, - "body": { - "type": "string", - "maxLength": 10000 - }, - "quote": { - "type": "string", - "format": "at-uri" - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "attachments": { - "type": "array", - "maxLength": 10, - "items": { - "type": "ref", - "ref": "#attachment" - } - }, - "updatedAt": { - "type": "string", - "format": "datetime" - } - } - } - }, - "attachment": { - "type": "object", - "required": ["file", "name"], - "properties": { - "file": { - "type": "blob", - "accept": ["*/*"], - "maxSize": 1000000 - }, - "name": { - "type": "string", - "maxLength": 256 - } - } - } - } -} diff --git a/telnet/server.py b/telnet/server.py index aa7cb97..0513bb5 100644 --- a/telnet/server.py +++ b/telnet/server.py @@ -123,10 +123,10 @@ async def show_board(writer, board, threads, has_next): if not threads: await write(writer, " No threads yet.\r\n") else: - for i, t in enumerate(threads, 1): - date = format_datetime_utc(t.created_at) + for index, thread in enumerate(threads, 1): + date = format_datetime_utc(thread.created_at) await write( - writer, f" {i}. {t.title} · {t.author.handle} · {date}\r\n" + writer, f" {index}. {thread.title} · {thread.author.handle} · {date}\r\n" ) cmds = ["[#] open thread"] @@ -149,11 +149,11 @@ async def show_thread_header(writer, thread): async def show_replies(writer, replies): - for r in replies: + for reply in replies: await write( - writer, f" {r.author.handle} · {format_datetime_utc(r.created_at)}\r\n" + writer, f" {reply.author.handle} · {format_datetime_utc(reply.created_at)}\r\n" ) - for line in r.body.splitlines(): + for line in reply.body.splitlines(): await write(writer, f" {line}\r\n") await write(writer, "\r\n") diff --git a/tui/screens/activity.py b/tui/screens/activity.py index cd0db6c..cf163fb 100644 --- a/tui/screens/activity.py +++ b/tui/screens/activity.py @@ -6,8 +6,8 @@ from textual.containers import VerticalScroll from textual.screen import Screen from textual.widgets import Footer, Static -from core.models import AtUri, Thread -from core.records import fetch_inbox +from core.models import AtUri, Post as PostModel +from core.records import fetch_inbox, post_from_record from core.resolver import resolve_bbs from core.slingshot import get_record, resolve_identity from tui.screens.thread import ThreadScreen @@ -33,7 +33,7 @@ class ActivityScreen(Screen): with VerticalScroll(id="activity-scroll"): yield Static("Inbox", classes="title") yield Static( - "Replies to your threads and quotes of your replies.", + "Replies to your threads from other users.", classes="subtitle", ) yield Static("Loading...", id="activity-loading") @@ -69,26 +69,17 @@ class ActivityScreen(Screen): async def _navigate(self, item: dict) -> None: parsed = AtUri.parse(item["thread_uri"]) thread_did = parsed.did - thread_tid = parsed.rkey + thread_rkey = parsed.rkey handle = item.get("bbs_handle") or self.app.user_session.get("handle", "") client = self.app.http_client try: bbs, rec, author = await asyncio.gather( resolve_bbs(client, handle), - get_record(client, thread_did, "xyz.atboards.thread", thread_tid), + get_record(client, thread_did, "xyz.atbbs.post", thread_rkey), resolve_identity(client, thread_did), ) - thread = Thread( - uri=rec.uri, - board_uri=rec.value["board"], - title=rec.value["title"], - body=rec.value["body"], - created_at=rec.value["createdAt"], - author=author, - updated_at=rec.value.get("updatedAt"), - attachments=rec.value.get("attachments"), - ) + thread = post_from_record(rec, author) self.app.push_screen( ThreadScreen(bbs, handle, thread, focus_reply=item.get("reply_uri")) ) @@ -120,11 +111,10 @@ class ActivityScreen(Screen): return for item in self._items[:50]: - title = ( - item["thread_title"] if item["type"] == "reply" else "quoted your reply" - ) if item["type"] == "reply": - title = f"on: {title}" + title = f"on: {item['thread_title']}" + else: + title = "replied to your reply" await scroll.mount( Post( author=item["handle"], diff --git a/tui/screens/board.py b/tui/screens/board.py index 4a04640..83df8a5 100644 --- a/tui/screens/board.py +++ b/tui/screens/board.py @@ -4,7 +4,7 @@ from textual.containers import VerticalScroll from textual.screen import Screen from textual.widgets import Button, Footer, ListItem, ListView, Static -from core.models import BBS, Board +from core.models import BBS, Board, Post as PostModel from core.records import hydrate_threads as fetch_threads from core.util import format_datetime_local as format_datetime from tui.screens.compose import ComposeThreadScreen diff --git a/tui/screens/compose/news.py b/tui/screens/compose/news.py index 4594980..159329f 100644 --- a/tui/screens/compose/news.py +++ b/tui/screens/compose/news.py @@ -6,7 +6,7 @@ from textual.widgets import Footer, Input, Static, TextArea from core import lexicon, limits from core.models import AtUri, AuthError, BBS -from core.records import create_news_record +from core.records import create_post_record from tui.util import require_session from tui.widgets.breadcrumb import Breadcrumb from tui.screens.compose.upload import upload_file @@ -32,7 +32,7 @@ class ComposeNewsScreen(Screen): with Vertical(): yield Static("news", classes="title") yield Input( - placeholder="Title", id="news-title", max_length=limits.NEWS_TITLE + placeholder="Title", id="news-title", max_length=limits.POST_TITLE ) yield TextArea(id="news-body", language=None) yield Input(placeholder="attach file (path, optional)", id="news-file") @@ -55,9 +55,9 @@ class ComposeNewsScreen(Screen): if not title or not body: self.notify("Title and body cannot be empty.", severity="error") return - if len(body) > limits.NEWS_BODY: + if len(body) > limits.POST_BODY: self.notify( - f"Body too long ({len(body)}/{limits.NEWS_BODY}).", severity="error" + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" ) return @@ -71,12 +71,12 @@ class ComposeNewsScreen(Screen): return try: - resp = await create_news_record( + resp = await create_post_record( self.app.http_client, session, - site_uri, - title, - body, + scope=site_uri, + body=body, + title=title, attachments=attachments or None, ) resp.raise_for_status() diff --git a/tui/screens/compose/reply.py b/tui/screens/compose/reply.py index 9e45af4..cf9836f 100644 --- a/tui/screens/compose/reply.py +++ b/tui/screens/compose/reply.py @@ -5,8 +5,8 @@ from textual.screen import Screen from textual.widgets import Footer, Input, Static, TextArea from core import limits -from core.models import AuthError, BBS, Reply, Thread -from core.records import create_reply_record +from core.models import AuthError, BBS, Post as PostModel +from core.records import create_post_record from tui.util import require_session from tui.widgets.breadcrumb import Breadcrumb from tui.screens.compose.upload import upload_file @@ -16,36 +16,36 @@ class ComposeReplyScreen(Screen): BINDINGS = [ ("escape", "app.pop_screen", "back"), ("ctrl+s", "post", "post"), - ("ctrl+g", "toggle_quote", "toggle quote"), + ("ctrl+g", "toggle_reply_to", "toggle reply to"), ] def __init__( - self, bbs: BBS, handle: str, thread: Thread, quote: Reply | None = None + self, bbs: BBS, handle: str, thread: PostModel, parent: PostModel | None = None ) -> None: super().__init__() self.bbs = bbs self.handle = handle - self._original_quote = quote - self.quote = quote + self.original_parent = parent + self.parent_post = parent self.thread = thread def compose(self) -> ComposeResult: yield Breadcrumb( ("@bbs", 3), (self.bbs.site.name, 2), - (self.thread.title, 1), + (self.thread.title or "", 1), ("reply", 0), ) with Vertical(): yield Static(f"reply to: {self.thread.title}", classes="title") - if self.quote: - body_preview = self.quote.body[:60] + ( - "..." if len(self.quote.body) > 60 else "" + if self.parent_post: + body_preview = self.parent_post.body[:60] + ( + "..." if len(self.parent_post.body) > 60 else "" ) yield Static( - f"quoting {self.quote.author.handle}: {body_preview}", + f"replying to {self.parent_post.author.handle}: {body_preview}", classes="subtitle", - id="quote-info", + id="reply-to-info", ) yield TextArea(id="reply-body", language=None) yield Input(placeholder="attach file (path, optional)", id="reply-file") @@ -54,24 +54,24 @@ class ComposeReplyScreen(Screen): def on_mount(self) -> None: self.query_one("#reply-body", TextArea).focus() - def action_toggle_quote(self) -> None: - if not self._original_quote: + def action_toggle_reply_to(self) -> None: + if not self.original_parent: return - if self.quote: - self.quote = None - for widget in self.query("#quote-info"): + if self.parent_post: + self.parent_post = None + for widget in self.query("#reply-to-info"): widget.remove() else: - self.quote = self._original_quote - body_preview = self.quote.body[:60] + ( - "..." if len(self.quote.body) > 60 else "" + self.parent_post = self.original_parent + body_preview = self.parent_post.body[:60] + ( + "..." if len(self.parent_post.body) > 60 else "" ) container = self.query_one(Vertical) container.mount( Static( - f"quoting {self.quote.author.handle}: {body_preview}", + f"replying to {self.parent_post.author.handle}: {body_preview}", classes="subtitle", - id="quote-info", + id="reply-to-info", ), before=self.query_one("#reply-body"), ) @@ -89,9 +89,9 @@ class ComposeReplyScreen(Screen): if not body: self.notify("Message body cannot be empty.", severity="error") return - if len(body) > limits.REPLY_BODY: + if len(body) > limits.POST_BODY: self.notify( - f"Body too long ({len(body)}/{limits.REPLY_BODY}).", severity="error" + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" ) return @@ -103,13 +103,14 @@ class ComposeReplyScreen(Screen): return try: - resp = await create_reply_record( + resp = await create_post_record( self.app.http_client, session, - self.thread.uri, - body, + scope=self.thread.scope, + body=body, + root=self.thread.uri, + parent=self.parent_post.uri if self.parent_post else None, attachments=attachments or None, - quote=self.quote.uri if self.quote else None, ) resp.raise_for_status() except AuthError: diff --git a/tui/screens/compose/thread.py b/tui/screens/compose/thread.py index a455e47..ac161a3 100644 --- a/tui/screens/compose/thread.py +++ b/tui/screens/compose/thread.py @@ -6,7 +6,7 @@ from textual.widgets import Footer, Input, Static, TextArea from core import lexicon, limits from core.models import AtUri, AuthError, BBS, Board -from core.records import create_thread_record +from core.records import create_post_record from tui.util import require_session from tui.widgets.breadcrumb import Breadcrumb from tui.screens.compose.upload import upload_file @@ -36,7 +36,7 @@ class ComposeThreadScreen(Screen): yield Input( placeholder="Thread title", id="thread-title", - max_length=limits.THREAD_TITLE, + max_length=limits.POST_TITLE, ) yield TextArea(id="thread-body", language=None) yield Input(placeholder="attach file (path, optional)", id="thread-file") @@ -59,9 +59,9 @@ class ComposeThreadScreen(Screen): if not title or not body: self.notify("Title and body cannot be empty.", severity="error") return - if len(body) > limits.THREAD_BODY: + if len(body) > limits.POST_BODY: self.notify( - f"Body too long ({len(body)}/{limits.THREAD_BODY}).", severity="error" + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" ) return @@ -75,12 +75,12 @@ class ComposeThreadScreen(Screen): return try: - resp = await create_thread_record( + resp = await create_post_record( self.app.http_client, session, - board_uri, - title, - body, + scope=board_uri, + body=body, + title=title, attachments=attachments or None, ) resp.raise_for_status() diff --git a/tui/screens/home.py b/tui/screens/home.py index 1943b46..4dacd0c 100644 --- a/tui/screens/home.py +++ b/tui/screens/home.py @@ -95,12 +95,6 @@ class HomeScreen(Screen): self.notify("Could not reach the network.", severity="error") return - # Check if banned - session = self.app.user_session - if session and bbs.site.is_banned(session.get("did")): - self.notify("You have been banned from this BBS.", severity="error") - return - self.app.push_screen(SiteScreen(bbs, handle)) self.query_one("#handle-input", Input).value = "" diff --git a/tui/screens/news.py b/tui/screens/news.py index d3c956a..329adf6 100644 --- a/tui/screens/news.py +++ b/tui/screens/news.py @@ -5,7 +5,7 @@ from textual.screen import Screen from textual.widgets import Footer from core import lexicon -from core.models import AuthError, BBS, News +from core.models import AtUri, AuthError, BBS, Post as PostModel from core.records import delete_record from tui.util import make_session_updater from tui.widgets.breadcrumb import Breadcrumb @@ -18,7 +18,7 @@ class NewsScreen(Screen): ("ctrl+d", "delete", "delete"), ] - def __init__(self, bbs: BBS, handle: str, news: News) -> None: + def __init__(self, bbs: BBS, handle: str, news: PostModel) -> None: super().__init__() self.bbs = bbs self.handle = handle @@ -53,12 +53,13 @@ class NewsScreen(Screen): async def _do_delete(self) -> None: session = self.app.user_session updater = make_session_updater(self.app.session_store) + rkey = AtUri.parse(self.news.uri).rkey try: await delete_record( self.app.http_client, session, - lexicon.NEWS, - self.news.tid, + lexicon.POST, + rkey, updater, ) self.app.pop_screen() diff --git a/tui/screens/site.py b/tui/screens/site.py index 2266d85..dd0d842 100644 --- a/tui/screens/site.py +++ b/tui/screens/site.py @@ -4,7 +4,7 @@ from textual.containers import VerticalScroll from textual.screen import Screen from textual.widgets import Footer, ListItem, ListView, Static -from core.models import BBS +from core.models import BBS, Post as PostModel from core.resolver import resolve_bbs from core.util import format_datetime_local as format_datetime from tui.screens.board import BoardScreen diff --git a/tui/screens/sysop/delete.py b/tui/screens/sysop/delete.py index 6f82654..14ffdd2 100644 --- a/tui/screens/sysop/delete.py +++ b/tui/screens/sysop/delete.py @@ -6,7 +6,7 @@ from textual.widgets import Button, Footer, Static from core import lexicon from core.models import AtUri, BBS -from core.constellation import get_news +from core.constellation import get_root_posts from core.records import delete_record, list_pds_records from tui.util import make_session_updater @@ -23,8 +23,8 @@ class SysopDeleteScreen(Screen): with Vertical(): yield Static("Delete your BBS?", classes="title") yield Static( - "This will delete your site record, all boards, news, " - "bans, and hidden post records. Threads and replies from " + "This will delete your site record, all boards, " + "bans, and hidden post records. Posts from " "users will remain in their repos.", ) yield Button("delete", id="delete-confirm", variant="error") @@ -54,17 +54,18 @@ class SysopDeleteScreen(Screen): except Exception: failed.append(f"board/{board.slug}") + # Delete sysop's news posts (posts scoped to site) site_uri = str(AtUri(session["did"], lexicon.SITE, "self")) try: - backlinks = await get_news(client, site_uri) + backlinks = await get_root_posts(client, site_uri) for ref in backlinks.records: if ref.did == session["did"]: try: await delete_record( - client, session, lexicon.NEWS, ref.rkey, updater + client, session, lexicon.POST, ref.rkey, updater ) except Exception: - failed.append(f"news/{ref.rkey}") + failed.append(f"post/{ref.rkey}") except Exception: failed.append("news lookup") diff --git a/tui/screens/sysop/edit.py b/tui/screens/sysop/edit.py index e2f616a..6f6261f 100644 --- a/tui/screens/sysop/edit.py +++ b/tui/screens/sysop/edit.py @@ -5,7 +5,7 @@ from textual.screen import Screen from textual.widgets import Footer, Input, Static, TextArea from core import lexicon, limits -from core.models import AuthError, BBS +from core.models import AtUri, AuthError, BBS from core.records import delete_record, put_board_record, put_site_record from core.resolver import invalidate_bbs_cache from core.util import now_iso @@ -183,7 +183,10 @@ class SysopEditScreen(Screen): "name": name, "description": description, "intro": intro, - "boards": [board["slug"] for board in self._boards], + "boards": [ + str(AtUri(session["did"], lexicon.BOARD, board["slug"])) + for board in self._boards + ], "createdAt": self.bbs.site.created_at or now, "updatedAt": now, }, diff --git a/tui/screens/thread.py b/tui/screens/thread.py index 1647a12..4047ef3 100644 --- a/tui/screens/thread.py +++ b/tui/screens/thread.py @@ -10,12 +10,12 @@ from textual.screen import Screen from textual.widgets import Footer, Static from core import lexicon -from core.models import BBS, AtUri, AuthError, Reply, Thread +from core.models import BBS, AtUri, AuthError, Post as PostModel from core.records import ( create_ban_record, create_hidden_record, delete_record, - reply_from_record, + post_from_record, ) from core.resolver import invalidate_bbs_cache from core.records import hydrate_replies as fetch_replies @@ -39,7 +39,7 @@ class ThreadScreen(Screen): ] def __init__( - self, bbs: BBS, handle: str, thread: Thread, focus_reply: str | None = None + self, bbs: BBS, handle: str, thread: PostModel, focus_reply: str | None = None ) -> None: super().__init__() self.bbs = bbs @@ -48,10 +48,11 @@ class ThreadScreen(Screen): self._focus_reply = focus_reply self._page: int = 1 self._total_pages: int = 1 - self._replies_map: dict[str, Reply] = {} + self._replies_map: dict[str, PostModel] = {} def compose(self) -> ComposeResult: - board_slug = AtUri.parse(self.thread.board_uri).rkey + scope_parsed = AtUri.parse(self.thread.scope) + board_slug = scope_parsed.rkey board_name = next( (b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug ) @@ -59,7 +60,7 @@ class ThreadScreen(Screen): ("@bbs", 3), (self.bbs.site.name, 2), (board_name, 1), - (self.thread.title, 0), + (self.thread.title or "", 0), ) with VerticalScroll(id="thread-scroll"): yield Post( @@ -70,7 +71,7 @@ class ThreadScreen(Screen): author_did=self.thread.author.did, author_pds=self.thread.author.pds, record_uri=self.thread.uri, - collection=lexicon.THREAD, + collection=lexicon.POST, attachments=self.thread.attachments, ) yield Static("", id="page-status-top", classes="page-status") @@ -91,9 +92,12 @@ class ThreadScreen(Screen): self.query_one("#page-status-top", Static).update(text) self.query_one("#page-status-bottom", Static).update(text) + def _is_reply_widget(self, post: Post) -> bool: + return post.record_uri is not None and post.record_uri != self.thread.uri + def _clear_replies(self) -> None: for post in self.query(Post): - if post.collection == lexicon.REPLY: + if self._is_reply_widget(post): post.remove() @work(exclusive=True) @@ -120,38 +124,38 @@ class ThreadScreen(Screen): for reply in result.replies: self._replies_map[reply.uri] = reply - # Fetch any quoted replies not already known (in parallel) - missing = [ - reply.quote + # Fetch any parent replies not already known (in parallel) + missing_parents = [ + reply.parent for reply in result.replies - if reply.quote and reply.quote not in self._replies_map + if reply.parent and reply.parent not in self._replies_map ] - async def fetch_quote(uri: str): + async def fetch_parent(uri: str): parsed = AtUri.parse(uri) record, author = await asyncio.gather( get_record(client, parsed.did, parsed.collection, parsed.rkey), resolve_identity(client, parsed.did), ) - return uri, reply_from_record(record, author) + return uri, post_from_record(record, author) - if missing: - quote_results = await asyncio.gather( - *[fetch_quote(uri) for uri in missing], + if missing_parents: + parent_results = await asyncio.gather( + *[fetch_parent(uri) for uri in missing_parents], return_exceptions=True, ) - for quote_result in quote_results: - if isinstance(quote_result, tuple): - self._replies_map[quote_result[0]] = quote_result[1] + for parent_result in parent_results: + if isinstance(parent_result, tuple): + self._replies_map[parent_result[0]] = parent_result[1] for reply in result.replies: - quote_text = None - if reply.quote and reply.quote in self._replies_map: - quoted = self._replies_map[reply.quote] - body_preview = quoted.body[:200] + ( - "..." if len(quoted.body) > 200 else "" + parent_preview = None + if reply.parent and reply.parent in self._replies_map: + parent_post = self._replies_map[reply.parent] + body_preview = parent_post.body[:200] + ( + "..." if len(parent_post.body) > 200 else "" ) - quote_text = f"{quoted.author.handle}: {body_preview}" + parent_preview = f"{parent_post.author.handle}: {body_preview}" await scroll.mount( Post( @@ -161,16 +165,16 @@ class ThreadScreen(Screen): author_did=reply.author.did, author_pds=reply.author.pds, record_uri=reply.uri, - collection=lexicon.REPLY, + collection=lexicon.POST, attachments=reply.attachments, - quote_text=quote_text, + parent_preview=parent_preview, ), before=self.query_one("#page-status-bottom"), ) # Focus first reply replies = [ - post for post in self.query(Post) if post.collection == lexicon.REPLY + post for post in self.query(Post) if self._is_reply_widget(post) ] if replies: replies[0].focus() @@ -243,18 +247,18 @@ class ThreadScreen(Screen): if not session: return - # If focused on a reply, quote it - quote = None + # If focused on a reply, set it as the parent + parent = None focused = self.focused if ( isinstance(focused, Post) - and focused.collection == lexicon.REPLY + and self._is_reply_widget(focused) and focused.record_uri ): - quote = self._replies_map.get(focused.record_uri) + parent = self._replies_map.get(focused.record_uri) self.app.push_screen( - ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote) + ComposeReplyScreen(self.bbs, self.handle, self.thread, parent=parent) ) def action_delete(self) -> None: @@ -278,7 +282,7 @@ class ThreadScreen(Screen): await delete_record( self.app.http_client, session, - post.collection, + lexicon.POST, post.rkey, ) except AuthError: @@ -288,7 +292,7 @@ class ThreadScreen(Screen): self.notify("Failed to delete.", severity="error") return - if post.collection == lexicon.THREAD: + if post.record_uri == self.thread.uri: self.app.pop_screen() else: await post.remove() diff --git a/tui/util.py b/tui/util.py index 0559e41..8f22352 100644 --- a/tui/util.py +++ b/tui/util.py @@ -4,14 +4,11 @@ from core.auth.session import SessionStore def require_session(screen) -> dict | None: - """Return the user session if logged in and not banned, else notify and return None.""" + """Return the user session if logged in, else notify and return None.""" session = screen.app.user_session if not session: screen.notify("You must be logged in to do that.", severity="error") return None - if screen.bbs.site.is_banned(session["did"]): - screen.notify("You have been banned from this BBS.", severity="error") - return None return session diff --git a/tui/widgets/post.py b/tui/widgets/post.py index b86831b..f544f91 100644 --- a/tui/widgets/post.py +++ b/tui/widgets/post.py @@ -66,7 +66,7 @@ class Post(Widget, can_focus=True): color: #8a8a8a; margin-top: 1; } - Post .post-quote { + Post .post-parent { color: #8a8a8a; border-left: solid #525252; padding-left: 2; @@ -85,7 +85,7 @@ class Post(Widget, can_focus=True): record_uri: str | None = None, collection: str | None = None, attachments: list[dict] | None = None, - quote_text: str | None = None, + parent_preview: str | None = None, **kwargs, ) -> None: super().__init__(**kwargs) @@ -98,7 +98,7 @@ class Post(Widget, can_focus=True): self.record_uri = record_uri self.collection = collection self.attachments = attachments or [] - self._quote_text = quote_text + self._parent_preview = parent_preview @property def rkey(self) -> str | None: @@ -110,8 +110,8 @@ class Post(Widget, can_focus=True): yield Static(f"{self._author} {self._date}", classes="post-meta", markup=False) if self._title: yield Static(self._title, classes="post-title", markup=False) - if self._quote_text: - yield Markdown(self._quote_text, classes="post-quote") + if self._parent_preview: + yield Markdown(self._parent_preview, classes="post-parent") yield Markdown(self._body, classes="post-body") for attachment in self.attachments: name = attachment.get("name", "file") diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh index e3df5b7..0d597d1 100755 --- a/web/docker-entrypoint.sh +++ b/web/docker-entrypoint.sh @@ -6,7 +6,7 @@ set -e # Strip trailing slash. PUBLIC_URL="${PUBLIC_URL%/}" -SCOPE="atproto blob:*/* repo:xyz.atboards.site repo:xyz.atboards.board repo:xyz.atboards.news repo:xyz.atboards.thread repo:xyz.atboards.reply repo:xyz.atboards.ban repo:xyz.atboards.hide repo:xyz.atboards.pin repo:xyz.atboards.profile" +SCOPE="atproto blob:*/* repo:xyz.atbbs.site repo:xyz.atbbs.board repo:xyz.atbbs.post repo:xyz.atbbs.ban repo:xyz.atbbs.hide repo:xyz.atbbs.pin repo:xyz.atbbs.profile" # Runtime config read by the SPA at startup. cat > /usr/share/nginx/html/config.json <

- {item.type === "quote" - ? "quoted your reply" + {item.type === "parent_reply" + ? "replied to your reply" : `on: ${item.threadTitle}`}

diff --git a/web/src/components/form/ComposeForm.tsx b/web/src/components/form/ComposeForm.tsx index 689e957..1ca63d9 100644 --- a/web/src/components/form/ComposeForm.tsx +++ b/web/src/components/form/ComposeForm.tsx @@ -17,8 +17,8 @@ interface ComposeFormProps { titleMaxLength?: number; files: File[]; onFilesChange: (files: File[]) => void; - quote?: { uri: string; handle: string } | null; - onClearQuote?: () => void; + replyingTo?: { uri: string; handle: string } | null; + onClearReplyTo?: () => void; submitLabel?: string; posting?: boolean; className?: string; @@ -35,8 +35,8 @@ export default function ComposeForm({ titlePlaceholder = "Title", files, onFilesChange, - quote, - onClearQuote, + replyingTo, + onClearReplyTo, bodyMaxLength, titleMaxLength, submitLabel = "post", @@ -60,13 +60,13 @@ export default function ComposeForm({ return (
- {quote && onClearQuote && ( + {replyingTo && onClearReplyTo && (
- quoting {quote.handle} + replying to {replyingTo.handle} )} - {onQuote && hasModActions && ( + {onReplyTo && hasModActions && (
)} diff --git a/web/src/components/post/ReplyCard.tsx b/web/src/components/post/ReplyCard.tsx index c29ceef..311e271 100644 --- a/web/src/components/post/ReplyCard.tsx +++ b/web/src/components/post/ReplyCard.tsx @@ -11,7 +11,7 @@ export interface Reply { pds: string; body: string; createdAt: string; - quote: string | null; + parent: string | null; attachments: { file: { ref: { $link: string } }; name: string }[]; } @@ -19,9 +19,9 @@ interface ReplyCardProps { reply: Reply; userDid: string; sysopDid: string; - quoted?: Reply; - onQuote: () => void; - onQuoteClick?: () => void; + parentPost?: Reply; + onReplyTo: () => void; + onParentClick?: () => void; onDelete: () => void; onBan: () => void; onHide: () => void; @@ -31,9 +31,9 @@ export default function ReplyCard({ reply, userDid, sysopDid, - quoted, - onQuote, - onQuoteClick, + parentPost, + onReplyTo, + onParentClick, onDelete, onBan, onHide, @@ -51,23 +51,23 @@ export default function ReplyCard({
- {quoted && ( + {parentPost && ( )} diff --git a/web/src/hooks/useThreadReplies.ts b/web/src/hooks/useThreadReplies.ts index 648b3aa..3205c6b 100644 --- a/web/src/hooks/useThreadReplies.ts +++ b/web/src/hooks/useThreadReplies.ts @@ -116,8 +116,8 @@ export function useThreadReplies(loaded: ThreadLoaderData) { const [replies, setReplies] = useState([]); const [loading, setLoading] = useState(true); - // All replies we've ever seen — accumulates across page changes so quotes - // and scroll targets always resolve, even for off-page replies. + // All replies we've ever seen — accumulates across page changes so parent + // previews and scroll targets always resolve, even for off-page replies. const [replyCache, setReplyCache] = useState>({}); // Pending scroll target — set when navigating to a reply on another page. @@ -142,13 +142,7 @@ export function useThreadReplies(loaded: ThreadLoaderData) { // Fetch records from Slingshot. const records = await getRecordsBatch(slice); - // Drop moderated content. - const visible = records.filter((r) => { - const { did } = parseAtUri(r.uri); - return ( - !bbs.site.bannedDids.has(did) && !bbs.site.hiddenPosts.has(r.uri) - ); - }); + const visible = records; // Resolve author handles and build Reply objects. const dids = visible.map((r) => parseAtUri(r.uri).did); @@ -174,20 +168,20 @@ export function useThreadReplies(loaded: ThreadLoaderData) { const newCache: Record = {}; for (const item of items) newCache[item.uri] = item; - // Fetch any quoted replies not already known - const missingQuotes = items - .filter((i) => i.quote && !newCache[i.quote!]) - .map((i) => i.quote!) + // Fetch any parent replies not already known + const missingParents = items + .filter((item) => item.parent && !newCache[item.parent!]) + .map((item) => item.parent!) .filter((uri) => !replyCache[uri]); - if (missingQuotes.length) { - const quoteRefs = [...new Set(missingQuotes)].map((uri) => + if (missingParents.length) { + const parentRefs = [...new Set(missingParents)].map((uri) => parseAtUri(uri), ); - const quoteRecords = await getRecordsBatch(quoteRefs); - const quoteDids = quoteRecords.map((r) => parseAtUri(r.uri).did); - const quoteAuthors = await resolveIdentitiesBatch(quoteDids); - for (const record of quoteRecords) { - const reply = recordToReply(record, quoteAuthors); + const parentRecords = await getRecordsBatch(parentRefs); + const parentDids = parentRecords.map((record) => parseAtUri(record.uri).did); + const parentAuthors = await resolveIdentitiesBatch(parentDids); + for (const record of parentRecords) { + const reply = recordToReply(record, parentAuthors); if (reply) newCache[reply.uri] = reply; } } diff --git a/web/src/lexicons/index.ts b/web/src/lexicons/index.ts index 5f60778..cfb62e1 100644 --- a/web/src/lexicons/index.ts +++ b/web/src/lexicons/index.ts @@ -1,9 +1,7 @@ -export * as XyzAtboardsBan from "./types/xyz/atboards/ban.js"; -export * as XyzAtboardsBoard from "./types/xyz/atboards/board.js"; -export * as XyzAtboardsHide from "./types/xyz/atboards/hide.js"; -export * as XyzAtboardsNews from "./types/xyz/atboards/news.js"; -export * as XyzAtboardsPin from "./types/xyz/atboards/pin.js"; -export * as XyzAtboardsProfile from "./types/xyz/atboards/profile.js"; -export * as XyzAtboardsReply from "./types/xyz/atboards/reply.js"; -export * as XyzAtboardsSite from "./types/xyz/atboards/site.js"; -export * as XyzAtboardsThread from "./types/xyz/atboards/thread.js"; +export * as XyzAtbbsBan from "./types/xyz/atbbs/ban.js"; +export * as XyzAtbbsBoard from "./types/xyz/atbbs/board.js"; +export * as XyzAtbbsHide from "./types/xyz/atbbs/hide.js"; +export * as XyzAtbbsPin from "./types/xyz/atbbs/pin.js"; +export * as XyzAtbbsPost from "./types/xyz/atbbs/post.js"; +export * as XyzAtbbsProfile from "./types/xyz/atbbs/profile.js"; +export * as XyzAtbbsSite from "./types/xyz/atbbs/site.js"; diff --git a/web/src/lexicons/types/xyz/atboards/ban.ts b/web/src/lexicons/types/xyz/atbbs/ban.ts similarity index 87% rename from web/src/lexicons/types/xyz/atboards/ban.ts rename to web/src/lexicons/types/xyz/atbbs/ban.ts index b38f84b..adc74ed 100644 --- a/web/src/lexicons/types/xyz/atboards/ban.ts +++ b/web/src/lexicons/types/xyz/atbbs/ban.ts @@ -5,7 +5,7 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.tidString(), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.ban"), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.ban"), createdAt: /*#__PURE__*/ v.datetimeString(), did: /*#__PURE__*/ v.didString(), }), @@ -21,6 +21,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.ban": mainSchema; + "xyz.atbbs.ban": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/board.ts b/web/src/lexicons/types/xyz/atbbs/board.ts similarity index 91% rename from web/src/lexicons/types/xyz/atboards/board.ts rename to web/src/lexicons/types/xyz/atbbs/board.ts index 2cc31ee..25fd53d 100644 --- a/web/src/lexicons/types/xyz/atboards/board.ts +++ b/web/src/lexicons/types/xyz/atbbs/board.ts @@ -5,7 +5,7 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.string(), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.board"), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.board"), createdAt: /*#__PURE__*/ v.datetimeString(), /** * @maxLength 500 @@ -33,6 +33,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.board": mainSchema; + "xyz.atbbs.board": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/hide.ts b/web/src/lexicons/types/xyz/atbbs/hide.ts similarity index 87% rename from web/src/lexicons/types/xyz/atboards/hide.ts rename to web/src/lexicons/types/xyz/atbbs/hide.ts index 298a8c2..56282ae 100644 --- a/web/src/lexicons/types/xyz/atboards/hide.ts +++ b/web/src/lexicons/types/xyz/atbbs/hide.ts @@ -5,7 +5,7 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.tidString(), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.hide"), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.hide"), createdAt: /*#__PURE__*/ v.datetimeString(), uri: /*#__PURE__*/ v.resourceUriString(), }), @@ -21,6 +21,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.hide": mainSchema; + "xyz.atbbs.hide": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/pin.ts b/web/src/lexicons/types/xyz/atbbs/pin.ts similarity index 82% rename from web/src/lexicons/types/xyz/atboards/pin.ts rename to web/src/lexicons/types/xyz/atbbs/pin.ts index 887d803..51180f3 100644 --- a/web/src/lexicons/types/xyz/atboards/pin.ts +++ b/web/src/lexicons/types/xyz/atbbs/pin.ts @@ -3,9 +3,9 @@ import * as v from "@atcute/lexicons/validations"; import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.record( - /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.string(), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.pin"), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.pin"), createdAt: /*#__PURE__*/ v.datetimeString(), did: /*#__PURE__*/ v.didString(), }), @@ -21,6 +21,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.pin": mainSchema; + "xyz.atbbs.pin": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/thread.ts b/web/src/lexicons/types/xyz/atbbs/post.ts similarity index 73% rename from web/src/lexicons/types/xyz/atboards/thread.ts rename to web/src/lexicons/types/xyz/atbbs/post.ts index 53d2fd1..1cef2bf 100644 --- a/web/src/lexicons/types/xyz/atboards/thread.ts +++ b/web/src/lexicons/types/xyz/atbbs/post.ts @@ -4,13 +4,14 @@ import type {} from "@atcute/lexicons/ambient"; const _attachmentSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.literal("xyz.atboards.thread#attachment"), + /*#__PURE__*/ v.literal("xyz.atbbs.post#attachment"), ), /** - * @accept *\/* * @maxSize 1000000 */ - file: /*#__PURE__*/ v.blob(), + file: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + ]), /** * @maxLength 256 */ @@ -21,7 +22,7 @@ const _attachmentSchema = /*#__PURE__*/ v.object({ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.tidString(), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.thread"), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.post"), /** * @maxLength 10 */ @@ -32,7 +33,6 @@ const _mainSchema = /*#__PURE__*/ v.record( ]), ); }, - board: /*#__PURE__*/ v.resourceUriString(), /** * @maxLength 10000 */ @@ -40,12 +40,17 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.stringLength(0, 10000), ]), createdAt: /*#__PURE__*/ v.datetimeString(), + parent: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + root: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), + scope: /*#__PURE__*/ v.resourceUriString(), /** * @maxLength 300 */ - title: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 300), - ]), + title: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 300), + ]), + ), updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), }), ); @@ -64,6 +69,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.thread": mainSchema; + "xyz.atbbs.post": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/profile.ts b/web/src/lexicons/types/xyz/atbbs/profile.ts similarity index 92% rename from web/src/lexicons/types/xyz/atboards/profile.ts rename to web/src/lexicons/types/xyz/atbbs/profile.ts index 40f7a0d..62684b6 100644 --- a/web/src/lexicons/types/xyz/atboards/profile.ts +++ b/web/src/lexicons/types/xyz/atbbs/profile.ts @@ -5,7 +5,7 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.literal("self"), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.profile"), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.profile"), /** * @maxLength 1000 */ @@ -44,6 +44,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.profile": mainSchema; + "xyz.atbbs.profile": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/site.ts b/web/src/lexicons/types/xyz/atbbs/site.ts similarity index 75% rename from web/src/lexicons/types/xyz/atboards/site.ts rename to web/src/lexicons/types/xyz/atbbs/site.ts index 2e591ed..4b10d52 100644 --- a/web/src/lexicons/types/xyz/atboards/site.ts +++ b/web/src/lexicons/types/xyz/atbbs/site.ts @@ -5,11 +5,8 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.literal("self"), /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.site"), - bannedDids: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), - ), - boards: /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), + $type: /*#__PURE__*/ v.literal("xyz.atbbs.site"), + boards: /*#__PURE__*/ v.array(/*#__PURE__*/ v.resourceUriString()), createdAt: /*#__PURE__*/ v.datetimeString(), /** * @maxLength 1000 @@ -17,9 +14,6 @@ const _mainSchema = /*#__PURE__*/ v.record( description: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ /*#__PURE__*/ v.stringLength(0, 1000), ]), - hiddenPosts: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.array(/*#__PURE__*/ v.resourceUriString()), - ), /** * @maxLength 5000 */ @@ -46,6 +40,6 @@ export interface Main extends v.InferInput {} declare module "@atcute/lexicons/ambient" { interface Records { - "xyz.atboards.site": mainSchema; + "xyz.atbbs.site": mainSchema; } } diff --git a/web/src/lexicons/types/xyz/atboards/news.ts b/web/src/lexicons/types/xyz/atboards/news.ts deleted file mode 100644 index 40fc397..0000000 --- a/web/src/lexicons/types/xyz/atboards/news.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; - -const _attachmentSchema = /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.literal("xyz.atboards.news#attachment"), - ), - /** - * @accept *\/* - * @maxSize 1000000 - */ - file: /*#__PURE__*/ v.blob(), - /** - * @maxLength 256 - */ - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 256), - ]), -}); -const _mainSchema = /*#__PURE__*/ v.record( - /*#__PURE__*/ v.tidString(), - /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.news"), - /** - * @maxLength 10 - */ - get attachments() { - return /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(attachmentSchema), [ - /*#__PURE__*/ v.arrayLength(0, 10), - ]), - ); - }, - /** - * @maxLength 10000 - */ - body: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 10000), - ]), - createdAt: /*#__PURE__*/ v.datetimeString(), - site: /*#__PURE__*/ v.resourceUriString(), - /** - * @maxLength 300 - */ - title: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 300), - ]), - }), -); - -type attachment$schematype = typeof _attachmentSchema; -type main$schematype = typeof _mainSchema; - -export interface attachmentSchema extends attachment$schematype {} -export interface mainSchema extends main$schematype {} - -export const attachmentSchema = _attachmentSchema as attachmentSchema; -export const mainSchema = _mainSchema as mainSchema; - -export interface Attachment extends v.InferInput {} -export interface Main extends v.InferInput {} - -declare module "@atcute/lexicons/ambient" { - interface Records { - "xyz.atboards.news": mainSchema; - } -} diff --git a/web/src/lexicons/types/xyz/atboards/reply.ts b/web/src/lexicons/types/xyz/atboards/reply.ts deleted file mode 100644 index 7608e03..0000000 --- a/web/src/lexicons/types/xyz/atboards/reply.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; - -const _attachmentSchema = /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.literal("xyz.atboards.reply#attachment"), - ), - /** - * @accept *\/* - * @maxSize 1000000 - */ - file: /*#__PURE__*/ v.blob(), - /** - * @maxLength 256 - */ - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 256), - ]), -}); -const _mainSchema = /*#__PURE__*/ v.record( - /*#__PURE__*/ v.tidString(), - /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("xyz.atboards.reply"), - /** - * @maxLength 10 - */ - get attachments() { - return /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(attachmentSchema), [ - /*#__PURE__*/ v.arrayLength(0, 10), - ]), - ); - }, - /** - * @maxLength 10000 - */ - body: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 10000), - ]), - createdAt: /*#__PURE__*/ v.datetimeString(), - quote: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), - subject: /*#__PURE__*/ v.resourceUriString(), - updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), - }), -); - -type attachment$schematype = typeof _attachmentSchema; -type main$schematype = typeof _mainSchema; - -export interface attachmentSchema extends attachment$schematype {} -export interface mainSchema extends main$schematype {} - -export const attachmentSchema = _attachmentSchema as attachmentSchema; -export const mainSchema = _mainSchema as mainSchema; - -export interface Attachment extends v.InferInput {} -export interface Main extends v.InferInput {} - -declare module "@atcute/lexicons/ambient" { - interface Records { - "xyz.atboards.reply": mainSchema; - } -} diff --git a/web/src/lib/activity.ts b/web/src/lib/activity.ts index 915fe98..3de4cbb 100644 --- a/web/src/lib/activity.ts +++ b/web/src/lib/activity.ts @@ -1,14 +1,13 @@ -/** Activity data — replies to your threads + quotes of your replies. */ +/** Activity data — replies to your posts from other users. */ import { fetchAndHydrate, listRecords } from "./atproto"; -import { THREAD, REPLY } from "./lexicon"; +import { POST } from "./lexicon"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; -import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; -import type { XyzAtboardsThread, XyzAtboardsReply } from "../lexicons"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../lexicons"; export interface ActivityItem { - type: "reply" | "quote"; + type: "reply" | "parent_reply"; threadTitle: string; threadUri: string; replyUri: string; @@ -49,43 +48,46 @@ export async function fetchActivity( pdsUrl: string, ): Promise { const SCAN_LIMIT = 50; - const [allThreads, allReplies] = await Promise.all([ - listRecords(pdsUrl, did, THREAD, SCAN_LIMIT), - listRecords(pdsUrl, did, REPLY, SCAN_LIMIT), - ]); - const threads = allThreads.filter((record) => is(threadSchema, record.value)); - const replies = allReplies.filter((record) => is(replySchema, record.value)); + const allPosts = await listRecords(pdsUrl, did, POST, SCAN_LIMIT); + const validPosts = allPosts.filter((record) => is(postSchema, record.value)); + + const rootPosts = validPosts.filter( + (record) => !(record.value as Record).root, + ); + const replyPosts = validPosts.filter( + (record) => !!(record.value as Record).root, + ); const results = await Promise.all([ - ...threads.map((thread) => { - const value = thread.value as unknown as XyzAtboardsThread.Main; + ...rootPosts.map((post) => { + const value = post.value as unknown as XyzAtbbsPost.Main; return fetchBacklinkItems( - thread.uri, - `${REPLY}:subject`, + post.uri, + `${POST}:root`, did, "reply", value.title ?? "", - thread.uri, + post.uri, ); }), - ...replies.map((reply) => { - const value = reply.value as unknown as XyzAtboardsReply.Main; + ...replyPosts.map((reply) => { + const value = reply.value as unknown as XyzAtbbsPost.Main; return fetchBacklinkItems( reply.uri, - `${REPLY}:quote`, + `${POST}:parent`, did, - "quote", + "parent_reply", "", - value.subject ?? "", + value.root ?? "", ); }), ]); - // Deduplicate — prefer "quote" type when the same reply appears as both. + // Deduplicate — prefer "parent-reply" type when the same reply appears as both. const seen = new Map(); for (const item of results.flat()) { const key = item.handle + item.body + item.createdAt; - if (!seen.has(key) || item.type === "quote") seen.set(key, item); + if (!seen.has(key) || item.type === "parent_reply") seen.set(key, item); } return [...seen.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt), diff --git a/web/src/lib/atproto.ts b/web/src/lib/atproto.ts index 2fbf146..6ef8538 100644 --- a/web/src/lib/atproto.ts +++ b/web/src/lib/atproto.ts @@ -128,8 +128,6 @@ export async function fetchAndHydrate( limit?: number; cursor?: string; excludeDid?: string; - bannedDids?: Set; - hiddenPosts?: Set; }, ): Promise { const limit = opts?.limit ?? 50; @@ -141,8 +139,6 @@ export async function fetchAndHydrate( const filtered = records.filter((record) => { const { did } = parseAtUri(record.uri); if (opts?.excludeDid && did === opts.excludeDid) return false; - if (opts?.bannedDids?.has(did)) return false; - if (opts?.hiddenPosts?.has(record.uri)) return false; return true; }); diff --git a/web/src/lib/bbs.ts b/web/src/lib/bbs.ts index d5f2417..fd440aa 100644 --- a/web/src/lib/bbs.ts +++ b/web/src/lib/bbs.ts @@ -5,25 +5,20 @@ import { getRecord, getRecordsBatch, getBacklinks, - listRecords, resolveIdentity, type MiniDoc, type ATRecord, } from "./atproto"; -import { SITE, BOARD, NEWS, BAN, HIDE } from "./lexicon"; +import { SITE, BOARD, POST, BAN, HIDE } from "./lexicon"; import { makeAtUri, parseAtUri } from "./util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; -import { mainSchema as boardSchema } from "../lexicons/types/xyz/atboards/board"; -import { mainSchema as newsSchema } from "../lexicons/types/xyz/atboards/news"; -import { mainSchema as banSchema } from "../lexicons/types/xyz/atboards/ban"; -import { mainSchema as hideSchema } from "../lexicons/types/xyz/atboards/hide"; +import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; +import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; import type { - XyzAtboardsSite, - XyzAtboardsBoard, - XyzAtboardsNews, - XyzAtboardsBan, - XyzAtboardsHide, + XyzAtbbsSite, + XyzAtbbsBoard, + XyzAtbbsPost, } from "../lexicons"; export class BBSNotFoundError extends Error {} @@ -38,18 +33,18 @@ export interface Board { updatedAt?: string; } -export interface NewsAttachment { +export interface PostAttachment { file: { ref: { $link: string } }; name: string; } -export interface News { - tid: string; - siteUri: string; +export interface NewsPost { + uri: string; + rkey: string; title: string; body: string; createdAt: string; - attachments?: NewsAttachment[]; + attachments?: PostAttachment[]; } export interface Site { @@ -57,8 +52,6 @@ export interface Site { description: string; intro: string; boards: Board[]; - bannedDids: Set; - hiddenPosts: Set; createdAt: string; updatedAt?: string; } @@ -66,7 +59,7 @@ export interface Site { export interface BBS { identity: MiniDoc; site: Site; - news: News[]; + news: NewsPost[]; } const bbsCache = new TTLCache(5 * 60 * 1000); @@ -104,27 +97,28 @@ async function _resolveBBS(handle: string): Promise { if (!is(siteSchema, siteRecord.value)) { throw new NoBBSError(`${handle} has an invalid site record.`); } - const siteValue = siteRecord.value as unknown as XyzAtboardsSite.Main; + const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; const siteUri = makeAtUri(identity.did, SITE, "self"); - const boardSlugs: string[] = siteValue.boards ?? []; - - const [boardResults, newsBacklinks, banRecords, hideRecords] = - await Promise.all([ - Promise.allSettled( - boardSlugs.map((slug) => getRecord(identity.did, BOARD, slug)), - ), - getBacklinks(siteUri, `${NEWS}:site`, 50).catch(() => null), - listRecords(identity.pds, identity.did, BAN).catch(() => []), - listRecords(identity.pds, identity.did, HIDE).catch(() => []), - ]); + const boardUris: string[] = siteValue.boards ?? []; + + const [boardResults, newsBacklinks] = await Promise.all([ + Promise.allSettled( + boardUris.map((uri) => { + const parsed = parseAtUri(uri); + return getRecord(parsed.did, parsed.collection, parsed.rkey); + }), + ), + getBacklinks(siteUri, `${POST}:scope`, 50).catch(() => null), + ]); const boards: Board[] = []; boardResults.forEach((result, index) => { if (result.status !== "fulfilled") return; if (!is(boardSchema, result.value.value)) return; - const board = result.value.value as unknown as XyzAtboardsBoard.Main; + const board = result.value.value as unknown as XyzAtbbsBoard.Main; + const parsed = parseAtUri(boardUris[index]); boards.push({ - slug: boardSlugs[index], + slug: parsed.rkey, name: board.name, description: board.description, createdAt: board.createdAt, @@ -132,40 +126,33 @@ async function _resolveBBS(handle: string): Promise { }); }); - // News - only sysop's repo - let news: News[] = []; + // News - posts scoped to the site, only sysop's repo + let news: NewsPost[] = []; if (newsBacklinks) { const sysopRefs = newsBacklinks.records.filter( (ref) => ref.did === identity.did, ); const newsRecords = await getRecordsBatch(sysopRefs); news = newsRecords - .filter((record) => is(newsSchema, record.value)) + .filter((record) => is(postSchema, record.value)) + .filter((record) => { + const value = record.value as unknown as XyzAtbbsPost.Main; + return value.title && !value.root; // root posts with titles are news/threads + }) .map((record) => { - const value = record.value as unknown as XyzAtboardsNews.Main; + const value = record.value as unknown as XyzAtbbsPost.Main; return { - tid: parseAtUri(record.uri).rkey, - siteUri: value.site, - title: value.title, + uri: record.uri, + rkey: parseAtUri(record.uri).rkey, + title: value.title ?? "", body: value.body, createdAt: value.createdAt, - attachments: value.attachments as NewsAttachment[] | undefined, + attachments: value.attachments as PostAttachment[] | undefined, }; }); news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } - const bannedDids = new Set( - banRecords - .filter((record) => is(banSchema, record.value)) - .map((record) => (record.value as unknown as XyzAtboardsBan.Main).did), - ); - const hiddenPosts = new Set( - hideRecords - .filter((record) => is(hideSchema, record.value)) - .map((record) => (record.value as unknown as XyzAtboardsHide.Main).uri), - ); - return { identity, site: { @@ -173,8 +160,6 @@ async function _resolveBBS(handle: string): Promise { description: siteValue.description, intro: siteValue.intro, boards, - bannedDids, - hiddenPosts, createdAt: siteValue.createdAt ?? "", updatedAt: siteValue.updatedAt, }, diff --git a/web/src/lib/deletebbs.ts b/web/src/lib/deletebbs.ts index 18bd79f..7c851c9 100644 --- a/web/src/lib/deletebbs.ts +++ b/web/src/lib/deletebbs.ts @@ -1,9 +1,9 @@ -/** Delete a user's entire BBS: boards, news, bans, hides, then the site record. */ +/** Delete a user's entire BBS: boards, news posts, bans, hides, then the site record. */ import type { Client } from "@atcute/client"; -import { getRecord, listRecords } from "./atproto"; -import { BAN, BOARD, HIDE, NEWS, SITE } from "./lexicon"; -import { parseAtUri } from "./util"; +import { getRecord, getBacklinks, listRecords } from "./atproto"; +import { BAN, BOARD, HIDE, POST, SITE } from "./lexicon"; +import { makeAtUri, parseAtUri } from "./util"; import { deleteRecord } from "./writes"; export async function deleteBBS(agent: Client, did: string, pdsUrl: string) { @@ -11,25 +11,35 @@ export async function deleteBBS(agent: Client, did: string, pdsUrl: string) { const existing = await getRecord(did, SITE, "self"); const siteValue = existing.value as Record; - const boardSlugs: string[] = ( + const boardUris: string[] = ( Array.isArray(siteValue.boards) ? siteValue.boards : [] ) as string[]; - for (const slug of boardSlugs) { + // Delete boards + for (const uri of boardUris) { try { - await deleteRecord(agent, BOARD, slug); + const { rkey } = parseAtUri(uri); + await deleteRecord(agent, BOARD, rkey); } catch { - failed.push(`board/${slug}`); + failed.push(`board/${uri}`); } } - const newsRecords = await listRecords(pdsUrl, did, NEWS); - for (const record of newsRecords) { - try { - await deleteRecord(agent, NEWS, parseAtUri(record.uri).rkey); - } catch { - failed.push(`news/${parseAtUri(record.uri).rkey}`); + // Delete sysop's news posts (posts scoped to the site) + const siteUri = makeAtUri(did, SITE, "self"); + try { + const backlinks = await getBacklinks(siteUri, `${POST}:scope`, 100); + for (const ref of backlinks.records) { + if (ref.did === did) { + try { + await deleteRecord(agent, POST, ref.rkey); + } catch { + failed.push(`post/${ref.rkey}`); + } + } } + } catch { + failed.push("news lookup"); } for (const collection of [BAN, HIDE]) { diff --git a/web/src/lib/lexicon.ts b/web/src/lib/lexicon.ts index 8d4bd1a..ac0ff90 100644 --- a/web/src/lib/lexicon.ts +++ b/web/src/lib/lexicon.ts @@ -1,9 +1,7 @@ -export const SITE = "xyz.atboards.site"; -export const BOARD = "xyz.atboards.board"; -export const NEWS = "xyz.atboards.news"; -export const THREAD = "xyz.atboards.thread"; -export const REPLY = "xyz.atboards.reply"; -export const BAN = "xyz.atboards.ban"; -export const HIDE = "xyz.atboards.hide"; -export const PIN = "xyz.atboards.pin"; -export const PROFILE = "xyz.atboards.profile"; +export const SITE = "xyz.atbbs.site"; +export const BOARD = "xyz.atbbs.board"; +export const POST = "xyz.atbbs.post"; +export const BAN = "xyz.atbbs.ban"; +export const HIDE = "xyz.atbbs.hide"; +export const PIN = "xyz.atbbs.pin"; +export const PROFILE = "xyz.atbbs.profile"; diff --git a/web/src/lib/limits.ts b/web/src/lib/limits.ts index 7c382e9..53f12d9 100644 --- a/web/src/lib/limits.ts +++ b/web/src/lib/limits.ts @@ -1,15 +1,12 @@ -/** Field length limits from the atboards lexicons. */ +/** Field length limits from the atbbs lexicons. */ export const SITE_NAME = 100; export const SITE_DESCRIPTION = 1000; export const SITE_INTRO = 5000; export const BOARD_NAME = 100; export const BOARD_DESCRIPTION = 500; -export const THREAD_TITLE = 300; -export const THREAD_BODY = 10000; -export const NEWS_TITLE = 300; -export const NEWS_BODY = 10000; -export const REPLY_BODY = 10000; +export const POST_TITLE = 300; +export const POST_BODY = 10000; export const ATTACHMENT_NAME = 256; export const MAX_ATTACHMENTS = 10; export const PROFILE_NAME = 100; diff --git a/web/src/lib/mythreads.ts b/web/src/lib/mythreads.ts index 811e332..ac8e8c9 100644 --- a/web/src/lib/mythreads.ts +++ b/web/src/lib/mythreads.ts @@ -1,11 +1,11 @@ -/** Fetch the user's own threads across all BBSes. */ +/** Fetch the user's own root posts (threads) across all BBSes. */ import { listRecords, resolveIdentitiesBatch } from "./atproto"; -import { THREAD } from "./lexicon"; +import { POST } from "./lexicon"; import { parseAtUri } from "./util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; -import type { XyzAtboardsThread } from "../lexicons"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../lexicons"; export interface MyThread { uri: string; @@ -21,30 +21,33 @@ export async function fetchMyThreads( pdsUrl: string, did: string, ): Promise { - const records = await listRecords(pdsUrl, did, THREAD); - const threadRecords = records.filter((record) => - is(threadSchema, record.value), - ); - if (!threadRecords.length) return []; + const records = await listRecords(pdsUrl, did, POST); + const rootPosts = records + .filter((record) => is(postSchema, record.value)) + .filter((record) => { + const value = record.value as Record; + return !value.root && value.title; // root posts with titles = threads + }); + if (!rootPosts.length) return []; const bbsDids = new Set( - threadRecords.map((record) => { - const value = record.value as unknown as XyzAtboardsThread.Main; - return parseAtUri(value.board).did; + rootPosts.map((record) => { + const value = record.value as unknown as XyzAtbbsPost.Main; + return parseAtUri(value.scope).did; }), ); const identities = await resolveIdentitiesBatch([...bbsDids]); const results: MyThread[] = []; - for (const record of threadRecords) { - const value = record.value as unknown as XyzAtboardsThread.Main; - const bbsDid = parseAtUri(value.board).did; + for (const record of rootPosts) { + const value = record.value as unknown as XyzAtbbsPost.Main; + const bbsDid = parseAtUri(value.scope).did; const identity = identities[bbsDid]; if (!identity) continue; results.push({ uri: record.uri, rkey: parseAtUri(record.uri).rkey, - title: value.title, + title: value.title ?? "", body: value.body, createdAt: value.createdAt, bbsDid, diff --git a/web/src/lib/pins.ts b/web/src/lib/pins.ts index b58768c..f7ec022 100644 --- a/web/src/lib/pins.ts +++ b/web/src/lib/pins.ts @@ -3,9 +3,9 @@ import { listRecords, getRecord, resolveIdentitiesBatch } from "./atproto"; import { PIN, SITE } from "./lexicon"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as pinSchema } from "../lexicons/types/xyz/atboards/pin"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; -import type { XyzAtboardsPin, XyzAtboardsSite } from "../lexicons"; +import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; +import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; +import type { XyzAtbbsPin, XyzAtbbsSite } from "../lexicons"; import { parseAtUri } from "./util"; export interface PinnedBBS { @@ -24,7 +24,7 @@ export async function fetchPins( const pinRecords = records.filter((record) => is(pinSchema, record.value)); const pinnedDids = pinRecords.map( - (record) => (record.value as unknown as XyzAtboardsPin.Main).did, + (record) => (record.value as unknown as XyzAtbbsPin.Main).did, ); if (!pinnedDids.length) return []; @@ -37,13 +37,13 @@ export async function fetchPins( siteResults.forEach((result, index) => { if (result.status !== "fulfilled") return; if (!is(siteSchema, result.value.value)) return; - const siteValue = result.value.value as unknown as XyzAtboardsSite.Main; + const siteValue = result.value.value as unknown as XyzAtbbsSite.Main; siteNames[pinnedDids[index]] = siteValue.name; }); const results: PinnedBBS[] = []; for (const record of pinRecords) { - const value = record.value as unknown as XyzAtboardsPin.Main; + const value = record.value as unknown as XyzAtbbsPin.Main; const identity = identities[value.did]; if (!identity) continue; results.push({ diff --git a/web/src/lib/profile.ts b/web/src/lib/profile.ts index 302480b..e9c6e1e 100644 --- a/web/src/lib/profile.ts +++ b/web/src/lib/profile.ts @@ -3,9 +3,9 @@ import { getRecord, resolveIdentity } from "./atproto"; import { PROFILE, SITE } from "./lexicon"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as profileSchema } from "../lexicons/types/xyz/atboards/profile"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; -import type { XyzAtboardsProfile, XyzAtboardsSite } from "../lexicons"; +import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile"; +import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; +import type { XyzAtbbsProfile, XyzAtbbsSite } from "../lexicons"; export interface Profile { did: string; @@ -43,7 +43,7 @@ export async function fetchProfile(handle: string): Promise { is(profileSchema, profileResult.value.value) ) { const value = profileResult.value - .value as unknown as XyzAtboardsProfile.Main; + .value as unknown as XyzAtbbsProfile.Main; profile.name = value.name; profile.pronouns = value.pronouns; profile.bio = value.bio; @@ -54,7 +54,7 @@ export async function fetchProfile(handle: string): Promise { siteResult.status === "fulfilled" && is(siteSchema, siteResult.value.value) ) { - const value = siteResult.value.value as unknown as XyzAtboardsSite.Main; + const value = siteResult.value.value as unknown as XyzAtbbsSite.Main; profile.bbsName = value.name; profile.bbsDescription = value.description; } diff --git a/web/src/lib/replies.ts b/web/src/lib/replies.ts index 2d3b94c..023970f 100644 --- a/web/src/lib/replies.ts +++ b/web/src/lib/replies.ts @@ -3,8 +3,8 @@ import { type BacklinkRef } from "./atproto"; import { parseAtUri } from "./util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; -import type { XyzAtboardsReply } from "../lexicons"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../lexicons"; import type { Reply } from "../components/post/ReplyCard"; export type { BacklinkRef }; @@ -49,8 +49,8 @@ export function recordToReply( ): Reply | null { const { did, rkey } = parseAtUri(record.uri); if (!(did in authors)) return null; - if (!is(replySchema, record.value)) return null; - const value = record.value as unknown as XyzAtboardsReply.Main; + if (!is(postSchema, record.value)) return null; + const value = record.value as unknown as XyzAtbbsPost.Main; return { uri: record.uri, did, @@ -59,7 +59,7 @@ export function recordToReply( pds: authors[did].pds ?? "", body: value.body, createdAt: value.createdAt, - quote: value.quote ?? null, + parent: value.parent ?? null, attachments: (value.attachments ?? []) as Reply["attachments"], }; } diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index dd60031..e280e93 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -29,10 +29,12 @@ export function parseAtUri(uri: string): { return { did: parts[2], collection: parts[3], rkey: parts[4] }; } +import type { Did } from "@atcute/lexicons/syntax"; + export function makeAtUri( did: string, collection: string, rkey: string, -): string { - return `at://${did}/${collection}/${rkey}`; +): `at://${Did}/${string}/${string}` { + return `at://${did as Did}/${collection}/${rkey}`; } diff --git a/web/src/lib/writes.ts b/web/src/lib/writes.ts index 9fd21be..42622c1 100644 --- a/web/src/lib/writes.ts +++ b/web/src/lib/writes.ts @@ -1,46 +1,32 @@ /** Authenticated PDS write helpers using an atcute Client from useAuth().agent. */ import type { Client } from "@atcute/client"; -import { - SITE, - BOARD, - NEWS, - THREAD, - REPLY, - BAN, - HIDE, - PIN, - PROFILE, -} from "./lexicon"; +import { SITE, BOARD, POST, BAN, HIDE, PIN, PROFILE } from "./lexicon"; import { invalidateBBSCache } from "./bbs"; import { nowIso } from "./util"; import { getCurrentUser } from "./auth"; import type { - XyzAtboardsThread, - XyzAtboardsReply, - XyzAtboardsSite, - XyzAtboardsBoard, - XyzAtboardsNews, - XyzAtboardsBan, - XyzAtboardsHide, - XyzAtboardsPin, - XyzAtboardsProfile, + XyzAtbbsPost, + XyzAtbbsSite, + XyzAtbbsBoard, + XyzAtbbsBan, + XyzAtbbsHide, + XyzAtbbsPin, + XyzAtbbsProfile, } from "../lexicons"; // --- Lexicon value types --- -// Strip $type so a single Attachment value works for both thread and reply. -type Attachment = Omit; +// Strip $type so a single Attachment value works for posts. +type Attachment = Omit; -type ThreadValue = Omit; -type ReplyValue = Omit; -type SiteValue = Omit; -type BoardValue = Omit; -type NewsValue = Omit; -type BanValue = Omit; -type HideValue = Omit; -type PinValue = Omit; -type ProfileValue = Omit; +type PostValue = Omit; +type SiteValue = Omit; +type BoardValue = Omit; +type BanValue = Omit; +type HideValue = Omit; +type PinValue = Omit; +type ProfileValue = Omit; interface BlobRef { $type: "blob"; @@ -164,43 +150,32 @@ export async function uploadAttachments( return out; } -// --- Threads & replies --- +// --- Posts (threads, replies, news) --- -export async function createThread( +export async function createPost( rpc: Client, - boardUri: string, - title: string, + scope: string, body: string, - attachments?: Attachment[], + opts?: { + title?: string; + root?: string; + parent?: string; + attachments?: Attachment[]; + }, ) { - const value: ThreadValue = { - board: boardUri as ThreadValue["board"], - title, + const value: PostValue = { + scope: scope as PostValue["scope"], body, createdAt: nowIso(), - ...(attachments?.length ? { attachments } : {}), + ...(opts?.title ? { title: opts.title } : {}), + ...(opts?.root ? { root: opts.root as PostValue["root"] } : {}), + ...(opts?.parent ? { parent: opts.parent as PostValue["parent"] } : {}), + ...(opts?.attachments?.length ? { attachments: opts.attachments } : {}), }; - return createRecord(rpc, THREAD, value); + return createRecord(rpc, POST, value); } -export async function createReply( - rpc: Client, - threadUri: string, - body: string, - quote?: string | null, - attachments?: Attachment[], -) { - const value: ReplyValue = { - subject: threadUri as ReplyValue["subject"], - body, - createdAt: nowIso(), - ...(quote ? { quote: quote as ReplyValue["quote"] } : {}), - ...(attachments?.length ? { attachments } : {}), - }; - return createRecord(rpc, REPLY, value); -} - -// --- Sysop: site, board, news --- +// --- Sysop: site, board --- export async function putSite(rpc: Client, site: SiteValue) { const resp = await putRecord(rpc, SITE, "self", site); @@ -225,25 +200,6 @@ export async function putBoard( return resp; } -export async function createNews( - rpc: Client, - siteUri: string, - title: string, - body: string, - attachments?: Attachment[], -) { - const value: NewsValue = { - site: siteUri as NewsValue["site"], - title, - body, - createdAt: nowIso(), - ...(attachments?.length - ? { attachments: attachments as NewsValue["attachments"] } - : {}), - }; - return createRecord(rpc, NEWS, value); -} - // --- Sysop: bans & hides --- export async function createBan(rpc: Client, did: string) { @@ -273,7 +229,8 @@ export async function createPin(rpc: Client, did: string) { did: did as PinValue["did"], createdAt: nowIso(), }; - return createRecord(rpc, PIN, value); + // Use DID as rkey for idempotent pins + return createRecord(rpc, PIN, value, did); } // --- Profiles --- diff --git a/web/src/pages/BBS.tsx b/web/src/pages/BBS.tsx index 07f22f4..decb00f 100644 --- a/web/src/pages/BBS.tsx +++ b/web/src/pages/BBS.tsx @@ -2,9 +2,9 @@ import { useState, type SyntheticEvent } from "react"; import { Link, useRouteLoaderData } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { useBreadcrumb } from "../hooks/useBreadcrumb"; -import { createNews, deleteRecord, uploadAttachments } from "../lib/writes"; +import { createPost, deleteRecord, uploadAttachments } from "../lib/writes"; import ComposeForm from "../components/form/ComposeForm"; -import { NEWS, SITE } from "../lib/lexicon"; +import { POST, SITE } from "../lib/lexicon"; import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; import * as limits from "../lib/limits"; import { usePageTitle } from "../hooks/usePageTitle"; @@ -22,7 +22,7 @@ import { Megaphone, ChevronDown, } from "lucide-react"; -import type { News } from "../lib/bbs"; +import type { NewsPost } from "../lib/bbs"; import type { BBSLoaderData } from "../router/loaders"; import PostBody from "../components/post/PostBody"; @@ -32,7 +32,7 @@ export default function BBSPage() { const [newsTitle, setNewsTitle] = useState(""); const [newsBody, setNewsBody] = useState(""); const [newsFiles, setNewsFiles] = useState([]); - const [pendingNews, setPendingNews] = useState([]); + const [pendingNews, setPendingNews] = useState([]); const [deletedTids, setDeletedTids] = useState>(new Set()); const [showAllNews, setShowAllNews] = useState(false); @@ -42,11 +42,6 @@ export default function BBSPage() { ); usePageTitle(`${bbs.site.name} — atbbs`); - if (user && bbs.site.bannedDids.has(user.did)) - return ( -

You have been banned from this BBS.

- ); - const isSysop = user && user.did === bbs.identity.did; async function postNews(e: SyntheticEvent) { @@ -56,10 +51,13 @@ export default function BBSPage() { const body = newsBody.trim(); const siteUri = makeAtUri(bbs.identity.did, SITE, "self"); const attachments = await uploadAttachments(agent, newsFiles); - const resp = await createNews(agent, siteUri, title, body, attachments); - const tid = parseAtUri(resp.data.uri).rkey; + const resp = await createPost(agent, siteUri, body, { + title, + attachments, + }); + const rkey = parseAtUri(resp.data.uri).rkey; setPendingNews((prev) => [ - { tid, siteUri, title, body, createdAt: nowIso() }, + { uri: resp.data.uri, rkey, title, body, createdAt: nowIso() }, ...prev, ]); setNewsTitle(""); @@ -67,21 +65,21 @@ export default function BBSPage() { setNewsFiles([]); } - async function removeNews(tid: string) { + async function removeNews(rkey: string) { if (!agent) return; if (!confirm("Delete this news post?")) return; - await deleteRecord(agent, NEWS, tid); - setPendingNews((prev) => prev.filter((n) => n.tid !== tid)); - setDeletedTids((prev) => new Set(prev).add(tid)); + await deleteRecord(agent, POST, rkey); + setPendingNews((prev) => prev.filter((n) => n.rkey !== rkey)); + setDeletedTids((prev) => new Set(prev).add(rkey)); } - // Merge pending news with loader data, deduplicating by tid and filtering deletes. - const loaderTids = new Set(bbs.news.map((n) => n.tid)); + // Merge pending news with loader data, deduplicating by rkey and filtering deletes. + const loaderTids = new Set(bbs.news.map((n) => n.rkey)); const allNews = [ ...pendingNews.filter( - (n) => !loaderTids.has(n.tid) && !deletedTids.has(n.tid), + (n) => !loaderTids.has(n.rkey) && !deletedTids.has(n.rkey), ), - ...bbs.news.filter((n) => !deletedTids.has(n.tid)), + ...bbs.news.filter((n) => !deletedTids.has(n.rkey)), ]; const visibleNews = showAllNews ? allNews : allNews.slice(0, 3); @@ -146,12 +144,12 @@ export default function BBSPage() { title={newsTitle} onTitleChange={setNewsTitle} titlePlaceholder="Headline" - titleMaxLength={limits.NEWS_TITLE} + titleMaxLength={limits.POST_TITLE} body={newsBody} onBodyChange={setNewsBody} bodyPlaceholder="Announcement body..." bodyRows={3} - bodyMaxLength={limits.NEWS_BODY} + bodyMaxLength={limits.POST_BODY} files={newsFiles} onFilesChange={setNewsFiles} submitLabel="post" @@ -163,8 +161,8 @@ export default function BBSPage() { <> {visibleNews.map((item, i) => (
@@ -179,7 +177,7 @@ export default function BBSPage() { type="button" onClick={(e) => { e.preventDefault(); - removeNews(item.tid); + removeNews(item.rkey); }} className="text-xs text-neutral-400 hover:text-red-400" > diff --git a/web/src/pages/Board.tsx b/web/src/pages/Board.tsx index f41886e..3a96e15 100644 --- a/web/src/pages/Board.tsx +++ b/web/src/pages/Board.tsx @@ -11,7 +11,7 @@ import { useBreadcrumb } from "../hooks/useBreadcrumb"; import { usePageTitle } from "../hooks/usePageTitle"; import { makeAtUri, parseAtUri, relativeDate } from "../lib/util"; import { BOARD } from "../lib/lexicon"; -import { createThread, uploadAttachments } from "../lib/writes"; +import { createPost, uploadAttachments } from "../lib/writes"; import * as limits from "../lib/limits"; import ThreadLink from "../components/nav/ThreadLink"; import ComposeForm from "../components/form/ComposeForm"; @@ -80,17 +80,12 @@ export default function BoardPage() { return; } try { - const { makeAtUri } = await import("../lib/util"); - const { BOARD: BOARD_COL } = await import("../lib/lexicon"); - const boardUri = makeAtUri(bbs.identity.did, BOARD_COL, board.slug); + const boardUri = makeAtUri(bbs.identity.did, BOARD, board.slug); const attachments = await uploadAttachments(agent, files); - const resp = await createThread( - agent, - boardUri, - title.trim(), - body.trim(), + const resp = await createPost(agent, boardUri, body.trim(), { + title: title.trim(), attachments, - ); + }); setTitle(""); setBody(""); setFiles([]); @@ -98,7 +93,7 @@ export default function BoardPage() { const { did, rkey } = parseAtUri(resp.data.uri); navigate(`/bbs/${handle}/thread/${did}/${rkey}`); } catch (err: unknown) { - console.error("createThread failed:", err); + console.error("createPost failed:", err); alert(`Could not post: ${err instanceof Error ? err.message : err}`); } } @@ -121,10 +116,10 @@ export default function BoardPage() { title={title} onTitleChange={setTitle} titlePlaceholder="Thread title" - titleMaxLength={limits.THREAD_TITLE} + titleMaxLength={limits.POST_TITLE} body={body} onBodyChange={setBody} - bodyMaxLength={limits.THREAD_BODY} + bodyMaxLength={limits.POST_BODY} files={files} onFilesChange={setFiles} /> diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 8f304c5..2c6d9e0 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -111,7 +111,7 @@ export default function Dashboard({ data }: { data: DashboardData }) { {tab === "inbox" && ( <>

- Recent replies and quotes from other users. + Recent replies from other users.

diff --git a/web/src/pages/News.tsx b/web/src/pages/News.tsx index 19a67c1..4194575 100644 --- a/web/src/pages/News.tsx +++ b/web/src/pages/News.tsx @@ -2,7 +2,7 @@ import { useNavigate, useParams, useRouteLoaderData } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { useBreadcrumb } from "../hooks/useBreadcrumb"; import { usePageTitle } from "../hooks/usePageTitle"; -import { NEWS } from "../lib/lexicon"; +import { POST } from "../lib/lexicon"; import { deleteRecord } from "../lib/writes"; import type { BBSLoaderData } from "../router/loaders"; import NewsCard from "../components/post/NewsCard"; @@ -13,7 +13,7 @@ export default function NewsPage() { const { user, agent } = useAuth(); const navigate = useNavigate(); - const item = bbs.news.find((news) => news.tid === tid); + const item = bbs.news.find((news) => news.rkey === tid); useBreadcrumb( [ @@ -35,7 +35,7 @@ export default function NewsPage() { async function onDelete() { if (!agent || !tid) return; if (!confirm("Delete this news post?")) return; - await deleteRecord(agent, NEWS, tid); + await deleteRecord(agent, POST, tid); navigate(`/bbs/${handle}`); } diff --git a/web/src/pages/SysopCreate.tsx b/web/src/pages/SysopCreate.tsx index 87fbde7..f56e262 100644 --- a/web/src/pages/SysopCreate.tsx +++ b/web/src/pages/SysopCreate.tsx @@ -2,7 +2,8 @@ import { useState, type SyntheticEvent } from "react"; import { useNavigate, useLoaderData } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { putBoard, putSite } from "../lib/writes"; -import { nowIso } from "../lib/util"; +import { BOARD } from "../lib/lexicon"; +import { makeAtUri, nowIso } from "../lib/util"; import * as limits from "../lib/limits"; import { usePageTitle } from "../hooks/usePageTitle"; import { Input, Textarea, Button } from "../components/form/Form"; @@ -59,7 +60,7 @@ export default function SysopCreate() { name: name.trim(), description: description.trim(), intro, - boards: cleanBoards.map((board) => board.slug), + boards: cleanBoards.map((board) => makeAtUri(user.did, BOARD, board.slug)), createdAt: now, }); navigate(`/bbs/${user.handle}`); diff --git a/web/src/pages/SysopEdit.tsx b/web/src/pages/SysopEdit.tsx index 2949eca..a2f41ce 100644 --- a/web/src/pages/SysopEdit.tsx +++ b/web/src/pages/SysopEdit.tsx @@ -2,7 +2,8 @@ import { useState, type SyntheticEvent } from "react"; import { useLoaderData, useNavigate } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { putBoard, putSite } from "../lib/writes"; -import { nowIso } from "../lib/util"; +import { BOARD } from "../lib/lexicon"; +import { makeAtUri, nowIso } from "../lib/util"; import * as limits from "../lib/limits"; import { usePageTitle } from "../hooks/usePageTitle"; import { Input, Textarea, Button } from "../components/form/Form"; @@ -61,7 +62,7 @@ export default function SysopEdit() { name: name.trim(), description: description.trim(), intro, - boards: cleanBoards.map((board) => board.slug), + boards: cleanBoards.map((board) => makeAtUri(user.did, BOARD, board.slug)), createdAt: bbs.site.createdAt || now, updatedAt: now, }); diff --git a/web/src/pages/SysopModerate.tsx b/web/src/pages/SysopModerate.tsx index 9814337..bb5005b 100644 --- a/web/src/pages/SysopModerate.tsx +++ b/web/src/pages/SysopModerate.tsx @@ -86,7 +86,7 @@ export default function SysopModerate() {
- {[...bbs.site.bannedDids].map((did) => ( + {Object.keys(banRkeys).map((did) => (
([]); - const [quote, setQuote] = useState<{ uri: string; handle: string } | null>( - null, - ); + const [replyingTo, setReplyingTo] = useState<{ + uri: string; + handle: string; + } | null>(null); const [posting, setPosting] = useState(false); usePageTitle(`${thread.title} — ${bbs.site.name}`); @@ -80,15 +81,15 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { if (!agent || !user) return; setPosting(true); try { - const threadUri = makeAtUri(thread.did, THREAD, thread.rkey); + const threadUri = makeAtUri(thread.did, POST, thread.rkey); const attachments = await uploadAttachments(agent, files); - const resp = await createReply( - agent, - threadUri, - body.trim(), - quote?.uri ?? null, + const { BOARD } = await import("../lib/lexicon"); + const boardUri = makeAtUri(bbs.identity.did, BOARD, thread.boardSlug); + const resp = await createPost(agent, boardUri, body.trim(), { + root: threadUri, + parent: replyingTo?.uri ?? undefined, attachments, - ); + }); addOptimisticReply({ uri: resp.data.uri, did: parseAtUri(resp.data.uri).did, @@ -97,12 +98,12 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { pds: user.pdsUrl, body: body.trim(), createdAt: new Date().toISOString(), - quote: quote?.uri ?? null, + parent: replyingTo?.uri ?? null, attachments: attachments as Reply["attachments"], }); setBody(""); setFiles([]); - setQuote(null); + setReplyingTo(null); } catch { alert("Could not post reply."); } finally { @@ -113,7 +114,7 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { async function onDeleteThread() { if (!agent) return; if (!confirm("Delete this thread?")) return; - await deleteRecord(agent, THREAD, thread.rkey); + await deleteRecord(agent, POST, thread.rkey); navigate(`/bbs/${handle}`); } @@ -121,7 +122,7 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { if (!agent) return; if (!confirm("Delete this reply?")) return; try { - await deleteRecord(agent, REPLY, reply.rkey); + await deleteRecord(agent, POST, reply.rkey); } catch (e: unknown) { console.error("deleteRecord failed:", e); alert(`Could not delete: ${e instanceof Error ? e.message : e}`); @@ -172,10 +173,16 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { reply={reply} userDid={user?.did ?? ""} sysopDid={bbs.identity.did} - quoted={reply.quote ? replyCache[reply.quote] : undefined} - onQuote={() => setQuote({ uri: reply.uri, handle: reply.handle })} - onQuoteClick={ - reply.quote ? () => scrollToReply(reply.quote!) : undefined + parentPost={ + reply.parent ? replyCache[reply.parent] : undefined + } + onReplyTo={() => + setReplyingTo({ uri: reply.uri, handle: reply.handle }) + } + onParentClick={ + reply.parent + ? () => scrollToReply(reply.parent!) + : undefined } onDelete={() => onDeleteReply(reply)} onBan={() => onBan(reply.did)} @@ -199,11 +206,11 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { onBodyChange={setBody} bodyPlaceholder="Write a reply..." bodyRows={3} - bodyMaxLength={limits.REPLY_BODY} + bodyMaxLength={limits.POST_BODY} files={files} onFilesChange={setFiles} - quote={quote} - onClearQuote={() => setQuote(null)} + replyingTo={replyingTo} + onClearReplyTo={() => setReplyingTo(null)} submitLabel="reply" posting={posting} /> diff --git a/web/src/router/loaders/board.ts b/web/src/router/loaders/board.ts index 3927fdb..168b414 100644 --- a/web/src/router/loaders/board.ts +++ b/web/src/router/loaders/board.ts @@ -6,11 +6,11 @@ import { resolveIdentitiesBatch, type ATRecord, } from "../../lib/atproto"; -import { THREAD, BOARD } from "../../lib/lexicon"; +import { POST, BOARD } from "../../lib/lexicon"; import { makeAtUri, parseAtUri } from "../../lib/util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread"; -import type { XyzAtboardsThread } from "../../lexicons"; +import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../../lexicons"; export interface ThreadItem { uri: string; @@ -28,15 +28,12 @@ export async function hydrateThreadPage( cursor?: string, ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); - const backlinks = await getBacklinks(boardUri, `${THREAD}:board`, 50, cursor); + const backlinks = await getBacklinks(boardUri, `${POST}:scope`, 50, cursor); const records = await getRecordsBatch(backlinks.records); const filtered = records.filter((record) => { - const { did } = parseAtUri(record.uri); - return ( - !bbs.site.bannedDids.has(did) && - !bbs.site.hiddenPosts.has(record.uri) && - is(threadSchema, record.value) - ); + if (!is(postSchema, record.value)) return false; + const value = record.value as unknown as XyzAtbbsPost.Main; + return value.title && !value.root; // root posts with titles = threads }); const authors = await resolveIdentitiesBatch( filtered.map((record) => parseAtUri(record.uri).did), @@ -45,13 +42,13 @@ export async function hydrateThreadPage( .filter((record) => parseAtUri(record.uri).did in authors) .map((record: ATRecord) => { const { did, rkey } = parseAtUri(record.uri); - const value = record.value as unknown as XyzAtboardsThread.Main; + const value = record.value as unknown as XyzAtbbsPost.Main; return { uri: record.uri, did, rkey, handle: authors[did].handle, - title: value.title, + title: value.title ?? "", body: value.body, createdAt: value.createdAt, }; diff --git a/web/src/router/loaders/sysop.ts b/web/src/router/loaders/sysop.ts index b1deace..0ea8321 100644 --- a/web/src/router/loaders/sysop.ts +++ b/web/src/router/loaders/sysop.ts @@ -8,9 +8,9 @@ import { import { BAN, HIDE } from "../../lib/lexicon"; import { parseAtUri } from "../../lib/util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as banSchema } from "../../lexicons/types/xyz/atboards/ban"; -import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atboards/hide"; -import type { XyzAtboardsBan, XyzAtboardsHide } from "../../lexicons"; +import { mainSchema as banSchema } from "../../lexicons/types/xyz/atbbs/ban"; +import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atbbs/hide"; +import type { XyzAtbbsBan, XyzAtbbsHide } from "../../lexicons"; import { requireAuth } from "./auth"; export interface HiddenInfo { @@ -33,18 +33,17 @@ function buildRkeyMap( return map; } -async function hydrateHiddenPosts(uris: Set): Promise { - if (uris.size === 0) return []; +async function hydrateHiddenPosts(uris: string[]): Promise { + if (uris.length === 0) return []; - const uriList = [...uris]; - const dids = [...new Set(uriList.map((uri) => parseAtUri(uri).did))]; + const dids = [...new Set(uris.map((uri) => parseAtUri(uri).did))]; const [identities, records] = await Promise.all([ resolveIdentitiesBatch(dids), - Promise.allSettled(uriList.map(getRecordByUri)), + Promise.allSettled(uris.map(getRecordByUri)), ]); - return uriList.map((uri, index) => { + return uris.map((uri, index) => { const did = parseAtUri(uri).did; const handle = identities[did]?.handle ?? did; const result = records[index]; @@ -91,29 +90,31 @@ export async function sysopModerateLoader() { listRecords(user.pdsUrl, user.did, HIDE), ]); - const banRkeys = buildRkeyMap( + const banRkeys = buildRkeyMap( banRecs, banSchema, (ban) => ban.did, ); - const hideRkeys = buildRkeyMap( + const hideRkeys = buildRkeyMap( hideRecs, hideSchema, (hide) => hide.uri, ); + const bannedDids = Object.keys(banRkeys); let bannedHandles: Record = {}; - if (bbs.site.bannedDids.size) { + if (bannedDids.length) { try { - const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]); - for (const did of bbs.site.bannedDids) + const authors = await resolveIdentitiesBatch(bannedDids); + for (const did of bannedDids) bannedHandles[did] = authors[did]?.handle ?? did; } catch { - for (const did of bbs.site.bannedDids) bannedHandles[did] = did; + for (const did of bannedDids) bannedHandles[did] = did; } } - const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts); + const hiddenUris = Object.keys(hideRkeys); + const hidden = await hydrateHiddenPosts(hiddenUris); return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden }; } diff --git a/web/src/router/loaders/thread.ts b/web/src/router/loaders/thread.ts index bb6206c..0b4238e 100644 --- a/web/src/router/loaders/thread.ts +++ b/web/src/router/loaders/thread.ts @@ -6,11 +6,11 @@ import { resolveIdentity, type BacklinkRef, } from "../../lib/atproto"; -import { THREAD, REPLY } from "../../lib/lexicon"; +import { POST } from "../../lib/lexicon"; import { makeAtUri, parseAtUri } from "../../lib/util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread"; -import type { XyzAtboardsThread } from "../../lexicons"; +import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../../lexicons"; export interface ThreadObj { uri: string; @@ -25,11 +25,11 @@ export interface ThreadObj { attachments?: { file: { ref: { $link: string } }; name: string }[]; } -async function collectAllReplyRefs(threadUri: string): Promise { +async function collectAllReplyRefs(rootUri: string): Promise { const collected: BacklinkRef[] = []; let cursor: string | undefined; for (let i = 0; i < 20; i++) { - const page = await getBacklinks(threadUri, `${REPLY}:subject`, 100, cursor); + const page = await getBacklinks(rootUri, `${POST}:root`, 100, cursor); collected.push(...page.records); if (!page.cursor) break; cursor = page.cursor; @@ -42,29 +42,29 @@ export async function threadLoader({ params }: LoaderFunctionArgs) { const did = params.did!; const tid = params.tid!; - const threadUri = makeAtUri(did, THREAD, tid); + const threadUri = makeAtUri(did, POST, tid); const [bbs, threadRecord, author, allRefs] = await Promise.all([ resolveBBS(handle), - getRecord(did, THREAD, tid), + getRecord(did, POST, tid), resolveIdentity(did), collectAllReplyRefs(threadUri), ]); - if (!is(threadSchema, threadRecord.value)) { - throw new Response("Invalid thread record", { status: 404 }); + if (!is(postSchema, threadRecord.value)) { + throw new Response("Invalid post record", { status: 404 }); } - const threadValue = threadRecord.value as unknown as XyzAtboardsThread.Main; - const boardSlug = parseAtUri(threadValue.board).rkey; + const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; + const boardSlug = parseAtUri(postValue.scope).rkey; const thread: ThreadObj = { uri: threadRecord.uri, did, rkey: tid, authorHandle: author.handle, authorPds: author.pds ?? "", - title: threadValue.title, - body: threadValue.body, - createdAt: threadValue.createdAt, + title: postValue.title ?? "", + body: postValue.body, + createdAt: postValue.createdAt, boardSlug, - attachments: threadValue.attachments as ThreadObj["attachments"], + attachments: postValue.attachments as ThreadObj["attachments"], }; return { handle, bbs, thread, allRefs }; diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 2260eeb..d67ef5b 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/boardroweditor.tsx","./src/components/composeform.tsx","./src/components/errorpage.tsx","./src/components/form.tsx","./src/components/layout.tsx","./src/components/localtime.tsx","./src/components/pagenav.tsx","./src/components/replycard.tsx","./src/hooks/usebreadcrumb.tsx","./src/hooks/usethreadreplies.ts","./src/hooks/usetitle.ts","./src/lexicons/index.ts","./src/lexicons/types/xyz/atboards/ban.ts","./src/lexicons/types/xyz/atboards/board.ts","./src/lexicons/types/xyz/atboards/hide.ts","./src/lexicons/types/xyz/atboards/news.ts","./src/lexicons/types/xyz/atboards/reply.ts","./src/lexicons/types/xyz/atboards/site.ts","./src/lexicons/types/xyz/atboards/thread.ts","./src/lib/atproto.ts","./src/lib/auth.ts","./src/lib/bbs.ts","./src/lib/lexicon.ts","./src/lib/util.ts","./src/lib/writes.ts","./src/pages/account.tsx","./src/pages/bbs.tsx","./src/pages/board.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/notfound.tsx","./src/pages/oauthcallback.tsx","./src/pages/sysopcreate.tsx","./src/pages/sysopedit.tsx","./src/pages/sysopmoderate.tsx","./src/pages/thread.tsx","./src/router/loaders.ts","./src/router/routes.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/ActivityList.tsx","./src/components/BBSPanel.tsx","./src/components/DialBBS.tsx","./src/components/DiscoveryList.tsx","./src/components/ErrorPage.tsx","./src/components/Localtime.tsx","./src/components/MyThreadList.tsx","./src/components/PinButton.tsx","./src/components/PinnedList.tsx","./src/components/form/BoardRowEditor.tsx","./src/components/form/ComposeForm.tsx","./src/components/form/FileChips.tsx","./src/components/form/Form.tsx","./src/components/form/HandleInput.tsx","./src/components/layout/Footer.tsx","./src/components/layout/Header.tsx","./src/components/layout/HeaderBreadcrumbs.tsx","./src/components/layout/Layout.tsx","./src/components/layout/Logo.tsx","./src/components/layout/MobileBackButton.tsx","./src/components/layout/MobileMenu.tsx","./src/components/nav/ActionBar.tsx","./src/components/nav/ActionButton.tsx","./src/components/nav/ListLink.tsx","./src/components/nav/PageNav.tsx","./src/components/nav/ThreadLink.tsx","./src/components/post/AttachmentLink.tsx","./src/components/post/NewsCard.tsx","./src/components/post/PostActions.tsx","./src/components/post/PostBody.tsx","./src/components/post/PostMeta.tsx","./src/components/post/ReplyCard.tsx","./src/components/post/ThreadCard.tsx","./src/components/profile/EditProfile.tsx","./src/components/profile/ViewProfile.tsx","./src/hooks/useBreadcrumb.tsx","./src/hooks/useDiscovery.ts","./src/hooks/useDropdown.ts","./src/hooks/useHandleSearch.ts","./src/hooks/usePageTitle.ts","./src/hooks/useResolvedBBS.ts","./src/hooks/useThreadReplies.ts","./src/lexicons/index.ts","./src/lexicons/types/xyz/atbbs/ban.ts","./src/lexicons/types/xyz/atbbs/board.ts","./src/lexicons/types/xyz/atbbs/hide.ts","./src/lexicons/types/xyz/atbbs/pin.ts","./src/lexicons/types/xyz/atbbs/post.ts","./src/lexicons/types/xyz/atbbs/profile.ts","./src/lexicons/types/xyz/atbbs/site.ts","./src/lib/activity.ts","./src/lib/atproto.ts","./src/lib/auth.ts","./src/lib/bbs.ts","./src/lib/bsky.ts","./src/lib/cache.ts","./src/lib/deletebbs.ts","./src/lib/lexicon.ts","./src/lib/limits.ts","./src/lib/mythreads.ts","./src/lib/pins.ts","./src/lib/profile.ts","./src/lib/replies.ts","./src/lib/util.ts","./src/lib/writes.ts","./src/pages/BBS.tsx","./src/pages/Board.tsx","./src/pages/Dashboard.tsx","./src/pages/Home.tsx","./src/pages/LoggedOutHome.tsx","./src/pages/Login.tsx","./src/pages/News.tsx","./src/pages/NotFound.tsx","./src/pages/OAuthCallback.tsx","./src/pages/Profile.tsx","./src/pages/SysopCreate.tsx","./src/pages/SysopEdit.tsx","./src/pages/SysopModerate.tsx","./src/pages/Thread.tsx","./src/router/routes.tsx","./src/router/loaders/account.ts","./src/router/loaders/auth.ts","./src/router/loaders/bbs.ts","./src/router/loaders/board.ts","./src/router/loaders/home.ts","./src/router/loaders/index.ts","./src/router/loaders/profile.ts","./src/router/loaders/sysop.ts","./src/router/loaders/thread.ts"],"version":"6.0.2"} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index 85d3bc5..9a35ee2 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,15 +8,13 @@ const SERVER_PORT = 5173; const SCOPE = [ "atproto", "blob:*/*", - "repo:xyz.atboards.site", - "repo:xyz.atboards.board", - "repo:xyz.atboards.news", - "repo:xyz.atboards.thread", - "repo:xyz.atboards.reply", - "repo:xyz.atboards.ban", - "repo:xyz.atboards.hide", - "repo:xyz.atboards.pin", - "repo:xyz.atboards.profile", + "repo:xyz.atbbs.site", + "repo:xyz.atbbs.board", + "repo:xyz.atbbs.post", + "repo:xyz.atbbs.ban", + "repo:xyz.atbbs.hide", + "repo:xyz.atbbs.pin", + "repo:xyz.atbbs.profile", ].join(" "); interface ClientMetadata { -- 2.51.2