Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

migrate lexicons!

+719 -1177
+3 -3
README.md
··· 81 81 82 82 atbbs has no backend database for content. All BBS data lives in atproto repos: 83 83 84 - - **Sysop records**: `xyz.atboards.site`, `xyz.atboards.board`, `xyz.atboards.news` 85 - - **Moderation records**: `xyz.atboards.ban`, `xyz.atboards.hide` 86 - - **User records**: `xyz.atboards.thread`, `xyz.atboards.reply` 84 + - **Sysop records**: `xyz.atbbs.site`, `xyz.atbbs.board` 85 + - **Moderation records**: `xyz.atbbs.ban`, `xyz.atbbs.hide` 86 + - **User records**: `xyz.atbbs.post`, `xyz.atbbs.pin`, `xyz.atbbs.profile` 87 87 88 88 The web app and TUI query existing network infrastructure: 89 89
+11 -27
core/constellation.py
··· 30 30 return BacklinksResponse( 31 31 total=data["total"], 32 32 records=[ 33 - BacklinkRef(did=r["did"], collection=r["collection"], rkey=r["rkey"]) 34 - for r in data["records"] 33 + BacklinkRef(did=entry["did"], collection=entry["collection"], rkey=entry["rkey"]) 34 + for entry in data["records"] 35 35 ], 36 36 cursor=data.get("cursor"), 37 37 ) 38 38 39 39 40 - async def get_threads( 41 - client: httpx.AsyncClient, 42 - board_uri: str, 43 - limit: int = 50, 44 - cursor: str | None = None, 45 - ) -> BacklinksResponse: 46 - """Get threads pointing at a board.""" 47 - return await get_backlinks( 48 - client, 49 - subject=board_uri, 50 - source=f"{lexicon.THREAD}:board", 51 - limit=limit, 52 - cursor=cursor, 53 - ) 54 - 55 - 56 - async def get_news( 40 + async def get_root_posts( 57 41 client: httpx.AsyncClient, 58 - site_uri: str, 42 + scope_uri: str, 59 43 limit: int = 50, 60 44 cursor: str | None = None, 61 45 ) -> BacklinksResponse: 62 - """Get news posts pointing at a site.""" 46 + """Get root posts (threads or news) pointing at a scope (board or site).""" 63 47 return await get_backlinks( 64 48 client, 65 - subject=site_uri, 66 - source=f"{lexicon.NEWS}:site", 49 + subject=scope_uri, 50 + source=f"{lexicon.POST}:scope", 67 51 limit=limit, 68 52 cursor=cursor, 69 53 ) ··· 71 55 72 56 async def get_replies( 73 57 client: httpx.AsyncClient, 74 - thread_uri: str, 58 + root_uri: str, 75 59 limit: int = 50, 76 60 cursor: str | None = None, 77 61 ) -> BacklinksResponse: 78 - """Get replies pointing at a thread.""" 62 + """Get replies pointing at a root post.""" 79 63 return await get_backlinks( 80 64 client, 81 - subject=thread_uri, 82 - source=f"{lexicon.REPLY}:subject", 65 + subject=root_uri, 66 + source=f"{lexicon.POST}:root", 83 67 limit=limit, 84 68 cursor=cursor, 85 69 )
+4 -4
core/filters.py
··· 1 - from core import lexicon 2 1 from core.models import AtUri, Record 3 2 4 3 ··· 7 6 ) -> list[Record]: 8 7 """Remove records from banned users or hidden by the sysop.""" 9 8 return [ 10 - r 11 - for r in records 12 - if AtUri.parse(r.uri).did not in banned_dids and r.uri not in hidden_posts 9 + record 10 + for record in records 11 + if AtUri.parse(record.uri).did not in banned_dids 12 + and record.uri not in hidden_posts 13 13 ]
+9 -11
core/lexicon.py
··· 1 - """AT Protocol collection names for atboards lexicons.""" 1 + """AT Protocol collection names for atbbs lexicons.""" 2 2 3 - SITE = "xyz.atboards.site" 4 - BOARD = "xyz.atboards.board" 5 - NEWS = "xyz.atboards.news" 6 - THREAD = "xyz.atboards.thread" 7 - REPLY = "xyz.atboards.reply" 8 - BAN = "xyz.atboards.ban" 9 - HIDE = "xyz.atboards.hide" 10 - PIN = "xyz.atboards.pin" 11 - PROFILE = "xyz.atboards.profile" 3 + SITE = "xyz.atbbs.site" 4 + BOARD = "xyz.atbbs.board" 5 + POST = "xyz.atbbs.post" 6 + BAN = "xyz.atbbs.ban" 7 + HIDE = "xyz.atbbs.hide" 8 + PIN = "xyz.atbbs.pin" 9 + PROFILE = "xyz.atbbs.profile" 12 10 13 - OAUTH_SCOPE = f"atproto blob:*/* repo:{SITE} repo:{BOARD} repo:{NEWS} repo:{THREAD} repo:{REPLY} repo:{BAN} repo:{HIDE} repo:{PIN} repo:{PROFILE}" 11 + OAUTH_SCOPE = f"atproto blob:*/* repo:{SITE} repo:{BOARD} repo:{POST} repo:{BAN} repo:{HIDE} repo:{PIN} repo:{PROFILE}"
+3 -6
core/limits.py
··· 1 - """Field length limits from the atboards lexicons.""" 1 + """Field length limits from the atbbs lexicons.""" 2 2 3 3 SITE_NAME = 100 4 4 SITE_DESCRIPTION = 1000 5 5 SITE_INTRO = 5000 6 6 BOARD_NAME = 100 7 7 BOARD_DESCRIPTION = 500 8 - THREAD_TITLE = 300 9 - THREAD_BODY = 10000 10 - NEWS_TITLE = 300 11 - NEWS_BODY = 10000 12 - REPLY_BODY = 10000 8 + POST_TITLE = 300 9 + POST_BODY = 10000 13 10 ATTACHMENT_NAME = 256 14 11 MAX_ATTACHMENTS = 10 15 12 PROFILE_NAME = 100
+16 -41
core/models.py
··· 96 96 97 97 @dataclass 98 98 class Board: 99 - """xyz.atboards.board — a subforum defined by the sysop.""" 99 + """xyz.atbbs.board — a subforum defined by the sysop.""" 100 100 101 101 slug: str 102 102 name: str ··· 106 106 107 107 108 108 @dataclass 109 - class News: 110 - """xyz.atboards.news — a sysop announcement.""" 109 + class Post: 110 + """xyz.atbbs.post — a thread, reply, or news item.""" 111 111 112 - tid: str 113 - site_uri: str 114 - title: str 112 + uri: str 113 + scope: str 115 114 body: str 116 115 created_at: str 116 + author: MiniDoc 117 + title: str | None = None 118 + root: str | None = None 119 + parent: str | None = None 120 + updated_at: str | None = None 117 121 attachments: list[dict] | None = None 118 122 123 + @property 124 + def is_root(self) -> bool: 125 + return self.root is None 126 + 119 127 120 128 @dataclass 121 129 class Site: 122 - """xyz.atboards.site/self — the BBS front door.""" 130 + """xyz.atbbs.site/self — the BBS front door.""" 123 131 124 132 name: str 125 133 description: str 126 134 intro: str 127 135 boards: list[Board] 128 - banned_dids: set[str] 129 - hidden_posts: set[str] 130 136 created_at: str 131 137 updated_at: str | None = None 132 138 133 - def is_banned(self, did: str) -> bool: 134 - return did in self.banned_dids 135 - 136 - 137 - @dataclass 138 - class Thread: 139 - """xyz.atboards.thread — a user's thread on a board.""" 140 - 141 - uri: str 142 - board_uri: str 143 - title: str 144 - body: str 145 - created_at: str 146 - author: MiniDoc 147 - updated_at: str | None = None 148 - attachments: list[dict] | None = None 149 - 150 - 151 - @dataclass 152 - class Reply: 153 - """xyz.atboards.reply — a user's reply to a thread.""" 154 - 155 - uri: str 156 - subject_uri: str 157 - body: str 158 - created_at: str 159 - author: MiniDoc 160 - updated_at: str | None = None 161 - attachments: list[dict] | None = None 162 - quote: str | None = None 163 - 164 139 165 140 @dataclass 166 141 class BBS: ··· 168 143 169 144 identity: MiniDoc 170 145 site: Site 171 - news: list[News] 146 + news: list[Post]
+111 -176
core/records.py
··· 8 8 import httpx 9 9 10 10 from core import lexicon 11 - from core.constellation import get_replies, get_threads 11 + from core.constellation import get_replies, get_root_posts 12 12 from core.filters import filter_moderated 13 - from core.models import AtUri, AuthError, BBS, Board, MiniDoc, Record, Reply, Thread 13 + from core.models import AtUri, AuthError, BBS, Board, MiniDoc, Post, Record 14 14 from core.slingshot import get_records_batch, resolve_identities_batch 15 15 from core.util import now_iso 16 16 17 17 18 - def thread_from_record(record: Record, author: MiniDoc) -> Thread: 19 - """Construct a Thread from a raw Record and resolved author.""" 20 - return Thread( 21 - uri=record.uri, 22 - board_uri=record.value["board"], 23 - title=record.value["title"], 24 - body=record.value["body"], 25 - created_at=record.value["createdAt"], 26 - author=author, 27 - updated_at=record.value.get("updatedAt"), 28 - attachments=record.value.get("attachments"), 29 - ) 30 - 31 - 32 - def reply_from_record(record: Record, author: MiniDoc) -> Reply: 33 - """Construct a Reply from a raw Record and resolved author.""" 34 - return Reply( 18 + def post_from_record(record: Record, author: MiniDoc) -> Post: 19 + """Construct a Post from a raw Record and resolved author.""" 20 + return Post( 35 21 uri=record.uri, 36 - subject_uri=record.value["subject"], 37 - body=record.value["body"], 38 - created_at=record.value["createdAt"], 22 + scope=record.value.get("scope", ""), 23 + body=record.value.get("body", ""), 24 + created_at=record.value.get("createdAt", ""), 39 25 author=author, 26 + title=record.value.get("title"), 27 + root=record.value.get("root"), 28 + parent=record.value.get("parent"), 40 29 updated_at=record.value.get("updatedAt"), 41 30 attachments=record.value.get("attachments"), 42 - quote=record.value.get("quote"), 43 31 ) 44 32 45 33 ··· 47 35 client: httpx.AsyncClient, 48 36 bbs: BBS, 49 37 board: Board, 38 + banned_dids: set[str] | None = None, 39 + hidden_posts: set[str] | None = None, 50 40 cursor: str | None = None, 51 - ) -> tuple[list[Thread], str | None]: 52 - """Fetch and hydrate threads for a board.""" 41 + ) -> tuple[list[Post], str | None]: 42 + """Fetch and hydrate root posts (threads) for a board.""" 53 43 board_uri = str(AtUri(bbs.identity.did, lexicon.BOARD, board.slug)) 54 - backlinks = await get_threads(client, board_uri, cursor=cursor) 44 + backlinks = await get_root_posts(client, board_uri, cursor=cursor) 55 45 records = await get_records_batch(client, backlinks.records) 56 - records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) 46 + if banned_dids or hidden_posts: 47 + records = filter_moderated(records, banned_dids or set(), hidden_posts or set()) 57 48 58 - parsed = {r.uri: AtUri.parse(r.uri) for r in records} 59 - dids = [p.did for p in parsed.values()] 49 + parsed = {record.uri: AtUri.parse(record.uri) for record in records} 50 + dids = [parsed_uri.did for parsed_uri in parsed.values()] 60 51 authors = await resolve_identities_batch(client, dids) 61 52 62 53 threads = [ 63 - thread_from_record(r, authors[parsed[r.uri].did]) 64 - for r in records 65 - if parsed[r.uri].did in authors 54 + post_from_record(record, authors[parsed[record.uri].did]) 55 + for record in records 56 + if parsed[record.uri].did in authors 66 57 ] 67 - threads.sort(key=lambda t: t.created_at, reverse=True) 58 + # Filter to root posts only (no root field = thread, not a reply) 59 + threads = [thread for thread in threads if thread.is_root] 60 + threads.sort(key=lambda thread: thread.created_at, reverse=True) 68 61 return threads, backlinks.cursor 69 62 70 63 ··· 72 65 class RepliesPage: 73 66 """A page of hydrated replies with pagination info.""" 74 67 75 - replies: list[Reply] 68 + replies: list[Post] 76 69 page: int 77 70 total_pages: int 78 71 total_replies: int ··· 81 74 async def hydrate_replies( 82 75 client: httpx.AsyncClient, 83 76 bbs: BBS, 84 - thread_uri: str, 77 + root_uri: str, 78 + banned_dids: set[str] | None = None, 79 + hidden_posts: set[str] | None = None, 85 80 page: int = 1, 86 81 page_size: int = 10, 87 82 focus_reply: str | None = None, ··· 92 87 containing that reply. 93 88 """ 94 89 # Fetch all refs (cheap — just did/collection/rkey) 95 - backlinks = await get_replies(client, thread_uri, limit=1000) 90 + backlinks = await get_replies(client, root_uri, limit=1000) 96 91 all_refs = list(reversed(backlinks.records)) # oldest first 97 92 98 93 total = len(all_refs) ··· 118 113 119 114 # Hydrate only this page 120 115 records = await get_records_batch(client, page_refs) 121 - records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) 116 + if banned_dids or hidden_posts: 117 + records = filter_moderated(records, banned_dids or set(), hidden_posts or set()) 122 118 123 - parsed = {r.uri: AtUri.parse(r.uri) for r in records} 124 - dids = [p.did for p in parsed.values()] 119 + parsed = {record.uri: AtUri.parse(record.uri) for record in records} 120 + dids = [parsed_uri.did for parsed_uri in parsed.values()] 125 121 authors = await resolve_identities_batch(client, dids) 126 122 127 123 replies = [ 128 - reply_from_record(r, authors[parsed[r.uri].did]) 129 - for r in records 130 - if parsed[r.uri].did in authors 124 + post_from_record(record, authors[parsed[record.uri].did]) 125 + for record in records 126 + if parsed[record.uri].did in authors 131 127 ] 132 - replies.sort(key=lambda t: t.created_at) 128 + replies.sort(key=lambda reply: reply.created_at) 133 129 return RepliesPage( 134 130 replies=replies, page=page, total_pages=total_pages, total_replies=total 135 131 ) ··· 274 270 return resp.json()["blob"] 275 271 276 272 277 - async def create_thread_record( 278 - client: httpx.AsyncClient, 279 - session: dict, 280 - board_uri: str, 281 - title: str, 282 - body: str, 283 - attachments: list[dict] | None = None, 284 - session_updater=None, 285 - ) -> httpx.Response: 286 - """Create a thread record in the user's repo.""" 287 - record = { 288 - "$type": lexicon.THREAD, 289 - "board": board_uri, 290 - "title": title, 291 - "body": body, 292 - "createdAt": now_iso(), 293 - } 294 - if attachments: 295 - record["attachments"] = attachments 296 - return await pds_post( 297 - client, 298 - session, 299 - "com.atproto.repo.createRecord", 300 - { 301 - "repo": session["did"], 302 - "collection": lexicon.THREAD, 303 - "record": record, 304 - }, 305 - session_updater, 306 - ) 307 - 308 - 309 - async def create_reply_record( 273 + async def create_post_record( 310 274 client: httpx.AsyncClient, 311 275 session: dict, 312 - thread_uri: str, 276 + scope: str, 313 277 body: str, 278 + title: str | None = None, 279 + root: str | None = None, 280 + parent: str | None = None, 314 281 attachments: list[dict] | None = None, 315 - quote: str | None = None, 316 282 session_updater=None, 317 283 ) -> httpx.Response: 318 - """Create a reply record in the user's repo.""" 319 - record = { 320 - "$type": lexicon.REPLY, 321 - "subject": thread_uri, 284 + """Create a post record in the user's repo.""" 285 + record: dict = { 286 + "$type": lexicon.POST, 287 + "scope": scope, 322 288 "body": body, 323 289 "createdAt": now_iso(), 324 290 } 291 + if title: 292 + record["title"] = title 293 + if root: 294 + record["root"] = root 295 + if parent: 296 + record["parent"] = parent 325 297 if attachments: 326 298 record["attachments"] = attachments 327 - if quote: 328 - record["quote"] = quote 329 299 return await pds_post( 330 300 client, 331 301 session, 332 302 "com.atproto.repo.createRecord", 333 303 { 334 304 "repo": session["did"], 335 - "collection": lexicon.REPLY, 305 + "collection": lexicon.POST, 336 306 "record": record, 337 307 }, 338 308 session_updater, ··· 486 456 ) 487 457 488 458 489 - async def create_news_record( 490 - client: httpx.AsyncClient, 491 - session: dict, 492 - site_uri: str, 493 - title: str, 494 - body: str, 495 - attachments: list[dict] | None = None, 496 - session_updater=None, 497 - ) -> httpx.Response: 498 - """Create a news record in the user's repo.""" 499 - record = { 500 - "$type": lexicon.NEWS, 501 - "site": site_uri, 502 - "title": title, 503 - "body": body, 504 - "createdAt": now_iso(), 505 - } 506 - if attachments: 507 - record["attachments"] = attachments 508 - return await pds_post( 509 - client, 510 - session, 511 - "com.atproto.repo.createRecord", 512 - { 513 - "repo": session["did"], 514 - "collection": lexicon.NEWS, 515 - "record": record, 516 - }, 517 - session_updater, 518 - ) 519 - 520 - 521 459 async def fetch_inbox( 522 460 client: httpx.AsyncClient, 523 461 did: str, 524 462 pds_url: str, 525 463 max_items: int = 50, 526 464 ) -> list[dict]: 527 - """Fetch inbox: replies to user's threads + quotes of user's replies.""" 465 + """Fetch inbox: replies to user's root posts and replies to user's replies.""" 528 466 import asyncio 529 467 530 468 from core.constellation import get_backlinks 531 469 532 - SCAN_LIMIT = 20 # how many threads/replies to scan 470 + SCAN_LIMIT = 20 # how many posts to scan 533 471 BACKLINK_LIMIT = 25 # backlinks per record 534 472 MAX_CONCURRENT = 10 # concurrent API calls 535 473 536 474 sem = asyncio.Semaphore(MAX_CONCURRENT) 537 475 538 - # Fetch thread and reply lists concurrently 539 - async def list_records(collection): 540 - try: 541 - resp = await client.get( 542 - f"{pds_url}/xrpc/com.atproto.repo.listRecords", 543 - params={"repo": did, "collection": collection, "limit": SCAN_LIMIT}, 544 - ) 545 - resp.raise_for_status() 546 - return resp.json().get("records", []) 547 - except Exception: 548 - return [] 476 + # Fetch user's posts 477 + try: 478 + resp = await client.get( 479 + f"{pds_url}/xrpc/com.atproto.repo.listRecords", 480 + params={"repo": did, "collection": lexicon.POST, "limit": SCAN_LIMIT}, 481 + ) 482 + resp.raise_for_status() 483 + all_posts = resp.json().get("records", []) 484 + except Exception: 485 + all_posts = [] 549 486 550 - thread_records, reply_records = await asyncio.gather( 551 - list_records(lexicon.THREAD), 552 - list_records(lexicon.REPLY), 553 - ) 487 + root_posts = [post for post in all_posts if "root" not in post["value"]] 488 + reply_posts = [post for post in all_posts if "root" in post["value"]] 554 489 555 - # Batch-resolve BBS handles for all threads at once 490 + # Batch-resolve BBS handles for all root posts at once 556 491 bbs_dids = set() 557 - for tr in thread_records: 558 - board_uri = tr["value"].get("board", "") 559 - if board_uri: 560 - bbs_dids.add(AtUri.parse(board_uri).did) 492 + for root_post in root_posts: 493 + scope = root_post["value"].get("scope", "") 494 + if scope: 495 + bbs_dids.add(AtUri.parse(scope).did) 561 496 try: 562 497 bbs_authors = ( 563 498 await resolve_identities_batch(client, list(bbs_dids)) if bbs_dids else {} ··· 565 500 except Exception: 566 501 bbs_authors = {} 567 502 568 - # 1. Fetch replies to user's threads (concurrent) 569 - async def fetch_thread_replies(tr): 503 + # 1. Fetch replies to user's root posts (concurrent) 504 + async def fetch_post_replies(root_post): 570 505 async with sem: 571 - thread_uri = tr["uri"] 572 - thread_title = tr["value"].get("title", "") 573 - board_uri = tr["value"].get("board", "") 574 - bbs_did = AtUri.parse(board_uri).did if board_uri else did 506 + post_uri = root_post["uri"] 507 + post_title = root_post["value"].get("title", "") 508 + scope = root_post["value"].get("scope", "") 509 + bbs_did = AtUri.parse(scope).did if scope else did 575 510 bbs_handle = bbs_authors[bbs_did].handle if bbs_did in bbs_authors else "" 576 511 577 512 try: 578 - backlinks = await get_replies(client, thread_uri, limit=BACKLINK_LIMIT) 513 + backlinks = await get_replies(client, post_uri, limit=BACKLINK_LIMIT) 579 514 records = await get_records_batch(client, backlinks.records) 580 - parsed = {r.uri: AtUri.parse(r.uri) for r in records} 581 - records = [r for r in records if parsed[r.uri].did != did] 515 + parsed = {record.uri: AtUri.parse(record.uri) for record in records} 516 + records = [record for record in records if parsed[record.uri].did != did] 582 517 if not records: 583 518 return [] 584 519 585 - dids = [parsed[r.uri].did for r in records] 520 + dids = [parsed[record.uri].did for record in records] 586 521 authors = await resolve_identities_batch(client, dids) 587 522 588 523 items = [] 589 - for r in records: 590 - author_did = parsed[r.uri].did 524 + for record in records: 525 + author_did = parsed[record.uri].did 591 526 if author_did not in authors: 592 527 continue 593 528 items.append( 594 529 { 595 530 "type": "reply", 596 - "reply_uri": r.uri, 597 - "thread_title": thread_title, 598 - "thread_uri": thread_uri, 531 + "reply_uri": record.uri, 532 + "thread_title": post_title, 533 + "thread_uri": post_uri, 599 534 "handle": authors[author_did].handle, 600 - "body": r.value.get("body", "")[:200], 601 - "created_at": r.value.get("createdAt", ""), 535 + "body": record.value.get("body", "")[:200], 536 + "created_at": record.value.get("createdAt", ""), 602 537 "bbs_handle": bbs_handle, 603 538 } 604 539 ) ··· 606 541 except Exception: 607 542 return [] 608 543 609 - # 2. Fetch quotes of user's replies (concurrent) 610 - async def fetch_reply_quotes(rr): 544 + # 2. Fetch replies that reference user's replies as parent (concurrent) 545 + async def fetch_child_replies(reply_post): 611 546 async with sem: 612 - reply_uri = rr["uri"] 613 - thread_uri = rr["value"].get("subject", "") 547 + reply_uri = reply_post["uri"] 548 + root_uri = reply_post["value"].get("root", "") 614 549 try: 615 550 backlinks = await get_backlinks( 616 551 client, 617 552 subject=reply_uri, 618 - source=f"{lexicon.REPLY}:quote", 553 + source=f"{lexicon.POST}:parent", 619 554 limit=BACKLINK_LIMIT, 620 555 ) 621 556 if not backlinks.records: 622 557 return [] 623 558 624 559 records = await get_records_batch(client, backlinks.records) 625 - parsed = {r.uri: AtUri.parse(r.uri) for r in records} 626 - records = [r for r in records if parsed[r.uri].did != did] 560 + parsed = {record.uri: AtUri.parse(record.uri) for record in records} 561 + records = [record for record in records if parsed[record.uri].did != did] 627 562 if not records: 628 563 return [] 629 564 630 - dids = [parsed[r.uri].did for r in records] 565 + dids = [parsed[record.uri].did for record in records] 631 566 authors = await resolve_identities_batch(client, dids) 632 567 633 568 items = [] 634 - for r in records: 635 - author_did = parsed[r.uri].did 569 + for record in records: 570 + author_did = parsed[record.uri].did 636 571 if author_did not in authors: 637 572 continue 638 573 items.append( 639 574 { 640 - "type": "quote", 641 - "reply_uri": r.uri, 575 + "type": "parent_reply", 576 + "reply_uri": record.uri, 642 577 "thread_title": "", 643 - "thread_uri": thread_uri, 578 + "thread_uri": root_uri, 644 579 "handle": authors[author_did].handle, 645 - "body": r.value.get("body", "")[:200], 646 - "created_at": r.value.get("createdAt", ""), 580 + "body": record.value.get("body", "")[:200], 581 + "created_at": record.value.get("createdAt", ""), 647 582 "bbs_handle": "", 648 583 } 649 584 ) ··· 653 588 654 589 # Run all lookups concurrently 655 590 results = await asyncio.gather( 656 - *[fetch_thread_replies(tr) for tr in thread_records], 657 - *[fetch_reply_quotes(rr) for rr in reply_records], 591 + *[fetch_post_replies(root_post) for root_post in root_posts], 592 + *[fetch_child_replies(reply_post) for reply_post in reply_posts], 658 593 ) 659 594 660 595 all_items = [] 661 596 for items in results: 662 597 all_items.extend(items) 663 598 664 - # Deduplicate and prefer quotes if same record appears in both 599 + # Deduplicate and prefer parent-reply type if same record appears in both 665 600 seen = {} 666 601 for item in all_items: 667 602 key = item["handle"] + item["body"] + item["created_at"] 668 603 if key in seen: 669 - if item["type"] == "quote": 604 + if item["type"] == "parent_reply": 670 605 seen[key] = item 671 606 else: 672 607 seen[key] = item 673 608 674 609 deduped = list(seen.values()) 675 - deduped.sort(key=lambda a: a["created_at"], reverse=True) 610 + deduped.sort(key=lambda item: item["created_at"], reverse=True) 676 611 return deduped
+37 -54
core/resolver.py
··· 6 6 AtUri, 7 7 BBS, 8 8 Board, 9 - News, 9 + Post, 10 10 Site, 11 11 BBSNotFoundError, 12 12 NoBBSError, ··· 14 14 ) 15 15 from core import lexicon 16 16 from core.cache import TTLCache 17 - from core.constellation import get_news 18 - from core.records import list_pds_records 17 + from core.constellation import get_root_posts 18 + from core.records import list_pds_records, post_from_record 19 19 from core.slingshot import get_record, get_records_batch, resolve_identity 20 20 21 21 _bbs_cache = TTLCache(ttl_seconds=300) # 5 minutes ··· 50 50 except httpx.TransportError: 51 51 raise NetworkError("Could not reach the network.") 52 52 53 - sv = site_record.value 53 + site_value = site_record.value 54 54 site_uri = str(AtUri(identity.did, lexicon.SITE, "self")) 55 55 56 - # Fetch boards, news, bans, and hidden posts concurrently 57 - board_slugs = sv["boards"] 58 - board_tasks = [ 59 - get_record(client, identity.did, lexicon.BOARD, slug) for slug in board_slugs 60 - ] 61 - news_task = get_news(client, site_uri) 62 - ban_task = list_pds_records(client, identity.pds, identity.did, lexicon.BAN) 63 - hidden_task = list_pds_records(client, identity.pds, identity.did, lexicon.HIDE) 56 + # Fetch boards and news concurrently 57 + board_uris = site_value["boards"] 58 + board_tasks = [] 59 + for uri in board_uris: 60 + parsed = AtUri.parse(uri) 61 + board_tasks.append(get_record(client, parsed.did, parsed.collection, parsed.rkey)) 62 + news_task = get_root_posts(client, site_uri) 64 63 65 64 results = await asyncio.gather( 66 - *board_tasks, news_task, ban_task, hidden_task, return_exceptions=True 65 + *board_tasks, news_task, return_exceptions=True 67 66 ) 68 - board_records = results[: len(board_slugs)] 69 - news_result = results[len(board_slugs)] 70 - ban_result = results[len(board_slugs) + 1] 71 - hidden_result = results[len(board_slugs) + 2] 67 + board_records = results[: len(board_uris)] 68 + news_result = results[len(board_uris)] 72 69 73 - boards = [ 74 - Board( 75 - slug=slug, 76 - name=r.value["name"], 77 - description=r.value["description"], 78 - created_at=r.value["createdAt"], 79 - updated_at=r.value.get("updatedAt"), 70 + boards = [] 71 + for uri, record in zip(board_uris, board_records): 72 + if isinstance(record, BaseException): 73 + continue 74 + parsed = AtUri.parse(uri) 75 + boards.append( 76 + Board( 77 + slug=parsed.rkey, 78 + name=record.value["name"], 79 + description=record.value["description"], 80 + created_at=record.value["createdAt"], 81 + updated_at=record.value.get("updatedAt"), 82 + ) 80 83 ) 81 - for slug, r in zip(board_slugs, board_records) 82 - if not isinstance(r, BaseException) 83 - ] 84 84 85 - # Hydrate news records (only from the sysop's repo) 85 + # Hydrate news posts (only from the sysop's repo) 86 86 if isinstance(news_result, BaseException): 87 87 news_records = [] 88 88 else: 89 - sysop_news = [r for r in news_result.records if r.did == identity.did] 89 + sysop_news = [ref for ref in news_result.records if ref.did == identity.did] 90 90 news_records = await get_records_batch(client, sysop_news) 91 91 news = [ 92 - News( 93 - tid=AtUri.parse(r.uri).rkey, 94 - site_uri=r.value["site"], 95 - title=r.value["title"], 96 - body=r.value["body"], 97 - created_at=r.value["createdAt"], 98 - attachments=r.value.get("attachments"), 99 - ) 100 - for r in news_records 92 + post_from_record(record, identity) 93 + for record in news_records 101 94 ] 102 - news.sort(key=lambda n: n.created_at, reverse=True) 103 - 104 - # Build ban/hidden sets from standalone records 105 - banned_dids: set[str] = set() 106 - if not isinstance(ban_result, BaseException): 107 - banned_dids = {r["value"]["did"] for r in ban_result} 108 - hidden_posts: set[str] = set() 109 - if not isinstance(hidden_result, BaseException): 110 - hidden_posts = {r["value"]["uri"] for r in hidden_result} 95 + news.sort(key=lambda post: post.created_at, reverse=True) 111 96 112 97 site = Site( 113 - name=sv["name"], 114 - description=sv["description"], 115 - intro=sv["intro"], 98 + name=site_value["name"], 99 + description=site_value["description"], 100 + intro=site_value["intro"], 116 101 boards=boards, 117 - banned_dids=banned_dids, 118 - hidden_posts=hidden_posts, 119 - created_at=sv.get("createdAt", ""), 120 - updated_at=sv.get("updatedAt"), 102 + created_at=site_value.get("createdAt", ""), 103 + updated_at=site_value.get("updatedAt"), 121 104 ) 122 105 123 106 return BBS(identity=identity, site=site, news=news)
+1 -1
lexicons/xyz.atboards.ban.json lexicons/xyz.atbbs.ban.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.ban", 3 + "id": "xyz.atbbs.ban", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record",
+1 -1
lexicons/xyz.atboards.board.json lexicons/xyz.atbbs.board.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.board", 3 + "id": "xyz.atbbs.board", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record",
+1 -1
lexicons/xyz.atboards.hide.json lexicons/xyz.atbbs.hide.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.hide", 3 + "id": "xyz.atbbs.hide", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record",
-60
lexicons/xyz.atboards.news.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "xyz.atboards.news", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "required": [ 11 - "site", 12 - "title", 13 - "body", 14 - "createdAt" 15 - ], 16 - "properties": { 17 - "site": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "title": { 22 - "type": "string", 23 - "maxLength": 300 24 - }, 25 - "body": { 26 - "type": "string", 27 - "maxLength": 10000 28 - }, 29 - "createdAt": { 30 - "type": "string", 31 - "format": "datetime" 32 - }, 33 - "attachments": { 34 - "type": "array", 35 - "maxLength": 10, 36 - "items": { 37 - "type": "ref", 38 - "ref": "#attachment" 39 - } 40 - } 41 - } 42 - } 43 - }, 44 - "attachment": { 45 - "type": "object", 46 - "required": ["file", "name"], 47 - "properties": { 48 - "file": { 49 - "type": "blob", 50 - "accept": ["*/*"], 51 - "maxSize": 1000000 52 - }, 53 - "name": { 54 - "type": "string", 55 - "maxLength": 256 56 - } 57 - } 58 - } 59 - } 60 - }
+2 -2
lexicons/xyz.atboards.pin.json lexicons/xyz.atbbs.pin.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.pin", 3 + "id": "xyz.atbbs.pin", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "key": "tid", 7 + "key": "any", 8 8 "record": { 9 9 "type": "object", 10 10 "required": ["did", "createdAt"],
+1 -1
lexicons/xyz.atboards.profile.json lexicons/xyz.atbbs.profile.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.profile", 3 + "id": "xyz.atbbs.profile", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record",
-63
lexicons/xyz.atboards.reply.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "xyz.atboards.reply", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "required": [ 11 - "subject", 12 - "body", 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "subject": { 17 - "type": "string", 18 - "format": "at-uri" 19 - }, 20 - "body": { 21 - "type": "string", 22 - "maxLength": 10000 23 - }, 24 - "quote": { 25 - "type": "string", 26 - "format": "at-uri" 27 - }, 28 - "createdAt": { 29 - "type": "string", 30 - "format": "datetime" 31 - }, 32 - "attachments": { 33 - "type": "array", 34 - "maxLength": 10, 35 - "items": { 36 - "type": "ref", 37 - "ref": "#attachment" 38 - } 39 - }, 40 - "updatedAt": { 41 - "type": "string", 42 - "format": "datetime" 43 - } 44 - } 45 - } 46 - }, 47 - "attachment": { 48 - "type": "object", 49 - "required": ["file", "name"], 50 - "properties": { 51 - "file": { 52 - "type": "blob", 53 - "accept": ["*/*"], 54 - "maxSize": 1000000 55 - }, 56 - "name": { 57 - "type": "string", 58 - "maxLength": 256 59 - } 60 - } 61 - } 62 - } 63 - }
+1 -13
lexicons/xyz.atboards.site.json lexicons/xyz.atbbs.site.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.site", 3 + "id": "xyz.atbbs.site", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", ··· 28 28 "maxLength": 5000 29 29 }, 30 30 "boards": { 31 - "type": "array", 32 - "items": { 33 - "type": "string" 34 - } 35 - }, 36 - "bannedDids": { 37 - "type": "array", 38 - "items": { 39 - "type": "string" 40 - } 41 - }, 42 - "hiddenPosts": { 43 31 "type": "array", 44 32 "items": { 45 33 "type": "string",
+15 -8
lexicons/xyz.atboards.thread.json lexicons/xyz.atbbs.post.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "xyz.atboards.thread", 3 + "id": "xyz.atbbs.post", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", ··· 8 8 "record": { 9 9 "type": "object", 10 10 "required": [ 11 - "board", 12 - "title", 11 + "scope", 13 12 "body", 14 13 "createdAt" 15 14 ], 16 15 "properties": { 17 - "board": { 16 + "scope": { 18 17 "type": "string", 19 18 "format": "at-uri" 20 19 }, ··· 26 25 "type": "string", 27 26 "maxLength": 10000 28 27 }, 28 + "root": { 29 + "type": "string", 30 + "format": "at-uri" 31 + }, 32 + "parent": { 33 + "type": "string", 34 + "format": "at-uri" 35 + }, 29 36 "createdAt": { 37 + "type": "string", 38 + "format": "datetime" 39 + }, 40 + "updatedAt": { 30 41 "type": "string", 31 42 "format": "datetime" 32 43 }, ··· 37 48 "type": "ref", 38 49 "ref": "#attachment" 39 50 } 40 - }, 41 - "updatedAt": { 42 - "type": "string", 43 - "format": "datetime" 44 51 } 45 52 } 46 53 }
+6 -6
telnet/server.py
··· 123 123 if not threads: 124 124 await write(writer, " No threads yet.\r\n") 125 125 else: 126 - for i, t in enumerate(threads, 1): 127 - date = format_datetime_utc(t.created_at) 126 + for index, thread in enumerate(threads, 1): 127 + date = format_datetime_utc(thread.created_at) 128 128 await write( 129 - writer, f" {i}. {t.title} · {t.author.handle} · {date}\r\n" 129 + writer, f" {index}. {thread.title} · {thread.author.handle} · {date}\r\n" 130 130 ) 131 131 132 132 cmds = ["[#] open thread"] ··· 149 149 150 150 151 151 async def show_replies(writer, replies): 152 - for r in replies: 152 + for reply in replies: 153 153 await write( 154 - writer, f" {r.author.handle} · {format_datetime_utc(r.created_at)}\r\n" 154 + writer, f" {reply.author.handle} · {format_datetime_utc(reply.created_at)}\r\n" 155 155 ) 156 - for line in r.body.splitlines(): 156 + for line in reply.body.splitlines(): 157 157 await write(writer, f" {line}\r\n") 158 158 await write(writer, "\r\n") 159 159
+9 -19
tui/screens/activity.py
··· 6 6 from textual.screen import Screen 7 7 from textual.widgets import Footer, Static 8 8 9 - from core.models import AtUri, Thread 10 - from core.records import fetch_inbox 9 + from core.models import AtUri, Post as PostModel 10 + from core.records import fetch_inbox, post_from_record 11 11 from core.resolver import resolve_bbs 12 12 from core.slingshot import get_record, resolve_identity 13 13 from tui.screens.thread import ThreadScreen ··· 33 33 with VerticalScroll(id="activity-scroll"): 34 34 yield Static("Inbox", classes="title") 35 35 yield Static( 36 - "Replies to your threads and quotes of your replies.", 36 + "Replies to your threads from other users.", 37 37 classes="subtitle", 38 38 ) 39 39 yield Static("Loading...", id="activity-loading") ··· 69 69 async def _navigate(self, item: dict) -> None: 70 70 parsed = AtUri.parse(item["thread_uri"]) 71 71 thread_did = parsed.did 72 - thread_tid = parsed.rkey 72 + thread_rkey = parsed.rkey 73 73 handle = item.get("bbs_handle") or self.app.user_session.get("handle", "") 74 74 75 75 client = self.app.http_client 76 76 try: 77 77 bbs, rec, author = await asyncio.gather( 78 78 resolve_bbs(client, handle), 79 - get_record(client, thread_did, "xyz.atboards.thread", thread_tid), 79 + get_record(client, thread_did, "xyz.atbbs.post", thread_rkey), 80 80 resolve_identity(client, thread_did), 81 81 ) 82 - thread = Thread( 83 - uri=rec.uri, 84 - board_uri=rec.value["board"], 85 - title=rec.value["title"], 86 - body=rec.value["body"], 87 - created_at=rec.value["createdAt"], 88 - author=author, 89 - updated_at=rec.value.get("updatedAt"), 90 - attachments=rec.value.get("attachments"), 91 - ) 82 + thread = post_from_record(rec, author) 92 83 self.app.push_screen( 93 84 ThreadScreen(bbs, handle, thread, focus_reply=item.get("reply_uri")) 94 85 ) ··· 120 111 return 121 112 122 113 for item in self._items[:50]: 123 - title = ( 124 - item["thread_title"] if item["type"] == "reply" else "quoted your reply" 125 - ) 126 114 if item["type"] == "reply": 127 - title = f"on: {title}" 115 + title = f"on: {item['thread_title']}" 116 + else: 117 + title = "replied to your reply" 128 118 await scroll.mount( 129 119 Post( 130 120 author=item["handle"],
+1 -1
tui/screens/board.py
··· 4 4 from textual.screen import Screen 5 5 from textual.widgets import Button, Footer, ListItem, ListView, Static 6 6 7 - from core.models import BBS, Board 7 + from core.models import BBS, Board, Post as PostModel 8 8 from core.records import hydrate_threads as fetch_threads 9 9 from core.util import format_datetime_local as format_datetime 10 10 from tui.screens.compose import ComposeThreadScreen
+8 -8
tui/screens/compose/news.py
··· 6 6 7 7 from core import lexicon, limits 8 8 from core.models import AtUri, AuthError, BBS 9 - from core.records import create_news_record 9 + from core.records import create_post_record 10 10 from tui.util import require_session 11 11 from tui.widgets.breadcrumb import Breadcrumb 12 12 from tui.screens.compose.upload import upload_file ··· 32 32 with Vertical(): 33 33 yield Static("news", classes="title") 34 34 yield Input( 35 - placeholder="Title", id="news-title", max_length=limits.NEWS_TITLE 35 + placeholder="Title", id="news-title", max_length=limits.POST_TITLE 36 36 ) 37 37 yield TextArea(id="news-body", language=None) 38 38 yield Input(placeholder="attach file (path, optional)", id="news-file") ··· 55 55 if not title or not body: 56 56 self.notify("Title and body cannot be empty.", severity="error") 57 57 return 58 - if len(body) > limits.NEWS_BODY: 58 + if len(body) > limits.POST_BODY: 59 59 self.notify( 60 - f"Body too long ({len(body)}/{limits.NEWS_BODY}).", severity="error" 60 + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" 61 61 ) 62 62 return 63 63 ··· 71 71 return 72 72 73 73 try: 74 - resp = await create_news_record( 74 + resp = await create_post_record( 75 75 self.app.http_client, 76 76 session, 77 - site_uri, 78 - title, 79 - body, 77 + scope=site_uri, 78 + body=body, 79 + title=title, 80 80 attachments=attachments or None, 81 81 ) 82 82 resp.raise_for_status()
+29 -28
tui/screens/compose/reply.py
··· 5 5 from textual.widgets import Footer, Input, Static, TextArea 6 6 7 7 from core import limits 8 - from core.models import AuthError, BBS, Reply, Thread 9 - from core.records import create_reply_record 8 + from core.models import AuthError, BBS, Post as PostModel 9 + from core.records import create_post_record 10 10 from tui.util import require_session 11 11 from tui.widgets.breadcrumb import Breadcrumb 12 12 from tui.screens.compose.upload import upload_file ··· 16 16 BINDINGS = [ 17 17 ("escape", "app.pop_screen", "back"), 18 18 ("ctrl+s", "post", "post"), 19 - ("ctrl+g", "toggle_quote", "toggle quote"), 19 + ("ctrl+g", "toggle_reply_to", "toggle reply to"), 20 20 ] 21 21 22 22 def __init__( 23 - self, bbs: BBS, handle: str, thread: Thread, quote: Reply | None = None 23 + self, bbs: BBS, handle: str, thread: PostModel, parent: PostModel | None = None 24 24 ) -> None: 25 25 super().__init__() 26 26 self.bbs = bbs 27 27 self.handle = handle 28 - self._original_quote = quote 29 - self.quote = quote 28 + self.original_parent = parent 29 + self.parent_post = parent 30 30 self.thread = thread 31 31 32 32 def compose(self) -> ComposeResult: 33 33 yield Breadcrumb( 34 34 ("@bbs", 3), 35 35 (self.bbs.site.name, 2), 36 - (self.thread.title, 1), 36 + (self.thread.title or "", 1), 37 37 ("reply", 0), 38 38 ) 39 39 with Vertical(): 40 40 yield Static(f"reply to: {self.thread.title}", classes="title") 41 - if self.quote: 42 - body_preview = self.quote.body[:60] + ( 43 - "..." if len(self.quote.body) > 60 else "" 41 + if self.parent_post: 42 + body_preview = self.parent_post.body[:60] + ( 43 + "..." if len(self.parent_post.body) > 60 else "" 44 44 ) 45 45 yield Static( 46 - f"quoting {self.quote.author.handle}: {body_preview}", 46 + f"replying to {self.parent_post.author.handle}: {body_preview}", 47 47 classes="subtitle", 48 - id="quote-info", 48 + id="reply-to-info", 49 49 ) 50 50 yield TextArea(id="reply-body", language=None) 51 51 yield Input(placeholder="attach file (path, optional)", id="reply-file") ··· 54 54 def on_mount(self) -> None: 55 55 self.query_one("#reply-body", TextArea).focus() 56 56 57 - def action_toggle_quote(self) -> None: 58 - if not self._original_quote: 57 + def action_toggle_reply_to(self) -> None: 58 + if not self.original_parent: 59 59 return 60 - if self.quote: 61 - self.quote = None 62 - for widget in self.query("#quote-info"): 60 + if self.parent_post: 61 + self.parent_post = None 62 + for widget in self.query("#reply-to-info"): 63 63 widget.remove() 64 64 else: 65 - self.quote = self._original_quote 66 - body_preview = self.quote.body[:60] + ( 67 - "..." if len(self.quote.body) > 60 else "" 65 + self.parent_post = self.original_parent 66 + body_preview = self.parent_post.body[:60] + ( 67 + "..." if len(self.parent_post.body) > 60 else "" 68 68 ) 69 69 container = self.query_one(Vertical) 70 70 container.mount( 71 71 Static( 72 - f"quoting {self.quote.author.handle}: {body_preview}", 72 + f"replying to {self.parent_post.author.handle}: {body_preview}", 73 73 classes="subtitle", 74 - id="quote-info", 74 + id="reply-to-info", 75 75 ), 76 76 before=self.query_one("#reply-body"), 77 77 ) ··· 89 89 if not body: 90 90 self.notify("Message body cannot be empty.", severity="error") 91 91 return 92 - if len(body) > limits.REPLY_BODY: 92 + if len(body) > limits.POST_BODY: 93 93 self.notify( 94 - f"Body too long ({len(body)}/{limits.REPLY_BODY}).", severity="error" 94 + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" 95 95 ) 96 96 return 97 97 ··· 103 103 return 104 104 105 105 try: 106 - resp = await create_reply_record( 106 + resp = await create_post_record( 107 107 self.app.http_client, 108 108 session, 109 - self.thread.uri, 110 - body, 109 + scope=self.thread.scope, 110 + body=body, 111 + root=self.thread.uri, 112 + parent=self.parent_post.uri if self.parent_post else None, 111 113 attachments=attachments or None, 112 - quote=self.quote.uri if self.quote else None, 113 114 ) 114 115 resp.raise_for_status() 115 116 except AuthError:
+8 -8
tui/screens/compose/thread.py
··· 6 6 7 7 from core import lexicon, limits 8 8 from core.models import AtUri, AuthError, BBS, Board 9 - from core.records import create_thread_record 9 + from core.records import create_post_record 10 10 from tui.util import require_session 11 11 from tui.widgets.breadcrumb import Breadcrumb 12 12 from tui.screens.compose.upload import upload_file ··· 36 36 yield Input( 37 37 placeholder="Thread title", 38 38 id="thread-title", 39 - max_length=limits.THREAD_TITLE, 39 + max_length=limits.POST_TITLE, 40 40 ) 41 41 yield TextArea(id="thread-body", language=None) 42 42 yield Input(placeholder="attach file (path, optional)", id="thread-file") ··· 59 59 if not title or not body: 60 60 self.notify("Title and body cannot be empty.", severity="error") 61 61 return 62 - if len(body) > limits.THREAD_BODY: 62 + if len(body) > limits.POST_BODY: 63 63 self.notify( 64 - f"Body too long ({len(body)}/{limits.THREAD_BODY}).", severity="error" 64 + f"Body too long ({len(body)}/{limits.POST_BODY}).", severity="error" 65 65 ) 66 66 return 67 67 ··· 75 75 return 76 76 77 77 try: 78 - resp = await create_thread_record( 78 + resp = await create_post_record( 79 79 self.app.http_client, 80 80 session, 81 - board_uri, 82 - title, 83 - body, 81 + scope=board_uri, 82 + body=body, 83 + title=title, 84 84 attachments=attachments or None, 85 85 ) 86 86 resp.raise_for_status()
-6
tui/screens/home.py
··· 95 95 self.notify("Could not reach the network.", severity="error") 96 96 return 97 97 98 - # Check if banned 99 - session = self.app.user_session 100 - if session and bbs.site.is_banned(session.get("did")): 101 - self.notify("You have been banned from this BBS.", severity="error") 102 - return 103 - 104 98 self.app.push_screen(SiteScreen(bbs, handle)) 105 99 self.query_one("#handle-input", Input).value = "" 106 100
+5 -4
tui/screens/news.py
··· 5 5 from textual.widgets import Footer 6 6 7 7 from core import lexicon 8 - from core.models import AuthError, BBS, News 8 + from core.models import AtUri, AuthError, BBS, Post as PostModel 9 9 from core.records import delete_record 10 10 from tui.util import make_session_updater 11 11 from tui.widgets.breadcrumb import Breadcrumb ··· 18 18 ("ctrl+d", "delete", "delete"), 19 19 ] 20 20 21 - def __init__(self, bbs: BBS, handle: str, news: News) -> None: 21 + def __init__(self, bbs: BBS, handle: str, news: PostModel) -> None: 22 22 super().__init__() 23 23 self.bbs = bbs 24 24 self.handle = handle ··· 53 53 async def _do_delete(self) -> None: 54 54 session = self.app.user_session 55 55 updater = make_session_updater(self.app.session_store) 56 + rkey = AtUri.parse(self.news.uri).rkey 56 57 try: 57 58 await delete_record( 58 59 self.app.http_client, 59 60 session, 60 - lexicon.NEWS, 61 - self.news.tid, 61 + lexicon.POST, 62 + rkey, 62 63 updater, 63 64 ) 64 65 self.app.pop_screen()
+1 -1
tui/screens/site.py
··· 4 4 from textual.screen import Screen 5 5 from textual.widgets import Footer, ListItem, ListView, Static 6 6 7 - from core.models import BBS 7 + from core.models import BBS, Post as PostModel 8 8 from core.resolver import resolve_bbs 9 9 from core.util import format_datetime_local as format_datetime 10 10 from tui.screens.board import BoardScreen
+7 -6
tui/screens/sysop/delete.py
··· 6 6 7 7 from core import lexicon 8 8 from core.models import AtUri, BBS 9 - from core.constellation import get_news 9 + from core.constellation import get_root_posts 10 10 from core.records import delete_record, list_pds_records 11 11 from tui.util import make_session_updater 12 12 ··· 23 23 with Vertical(): 24 24 yield Static("Delete your BBS?", classes="title") 25 25 yield Static( 26 - "This will delete your site record, all boards, news, " 27 - "bans, and hidden post records. Threads and replies from " 26 + "This will delete your site record, all boards, " 27 + "bans, and hidden post records. Posts from " 28 28 "users will remain in their repos.", 29 29 ) 30 30 yield Button("delete", id="delete-confirm", variant="error") ··· 54 54 except Exception: 55 55 failed.append(f"board/{board.slug}") 56 56 57 + # Delete sysop's news posts (posts scoped to site) 57 58 site_uri = str(AtUri(session["did"], lexicon.SITE, "self")) 58 59 try: 59 - backlinks = await get_news(client, site_uri) 60 + backlinks = await get_root_posts(client, site_uri) 60 61 for ref in backlinks.records: 61 62 if ref.did == session["did"]: 62 63 try: 63 64 await delete_record( 64 - client, session, lexicon.NEWS, ref.rkey, updater 65 + client, session, lexicon.POST, ref.rkey, updater 65 66 ) 66 67 except Exception: 67 - failed.append(f"news/{ref.rkey}") 68 + failed.append(f"post/{ref.rkey}") 68 69 except Exception: 69 70 failed.append("news lookup") 70 71
+5 -2
tui/screens/sysop/edit.py
··· 5 5 from textual.widgets import Footer, Input, Static, TextArea 6 6 7 7 from core import lexicon, limits 8 - from core.models import AuthError, BBS 8 + from core.models import AtUri, AuthError, BBS 9 9 from core.records import delete_record, put_board_record, put_site_record 10 10 from core.resolver import invalidate_bbs_cache 11 11 from core.util import now_iso ··· 183 183 "name": name, 184 184 "description": description, 185 185 "intro": intro, 186 - "boards": [board["slug"] for board in self._boards], 186 + "boards": [ 187 + str(AtUri(session["did"], lexicon.BOARD, board["slug"])) 188 + for board in self._boards 189 + ], 187 190 "createdAt": self.bbs.site.created_at or now, 188 191 "updatedAt": now, 189 192 },
+40 -36
tui/screens/thread.py
··· 10 10 from textual.widgets import Footer, Static 11 11 12 12 from core import lexicon 13 - from core.models import BBS, AtUri, AuthError, Reply, Thread 13 + from core.models import BBS, AtUri, AuthError, Post as PostModel 14 14 from core.records import ( 15 15 create_ban_record, 16 16 create_hidden_record, 17 17 delete_record, 18 - reply_from_record, 18 + post_from_record, 19 19 ) 20 20 from core.resolver import invalidate_bbs_cache 21 21 from core.records import hydrate_replies as fetch_replies ··· 39 39 ] 40 40 41 41 def __init__( 42 - self, bbs: BBS, handle: str, thread: Thread, focus_reply: str | None = None 42 + self, bbs: BBS, handle: str, thread: PostModel, focus_reply: str | None = None 43 43 ) -> None: 44 44 super().__init__() 45 45 self.bbs = bbs ··· 48 48 self._focus_reply = focus_reply 49 49 self._page: int = 1 50 50 self._total_pages: int = 1 51 - self._replies_map: dict[str, Reply] = {} 51 + self._replies_map: dict[str, PostModel] = {} 52 52 53 53 def compose(self) -> ComposeResult: 54 - board_slug = AtUri.parse(self.thread.board_uri).rkey 54 + scope_parsed = AtUri.parse(self.thread.scope) 55 + board_slug = scope_parsed.rkey 55 56 board_name = next( 56 57 (b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug 57 58 ) ··· 59 60 ("@bbs", 3), 60 61 (self.bbs.site.name, 2), 61 62 (board_name, 1), 62 - (self.thread.title, 0), 63 + (self.thread.title or "", 0), 63 64 ) 64 65 with VerticalScroll(id="thread-scroll"): 65 66 yield Post( ··· 70 71 author_did=self.thread.author.did, 71 72 author_pds=self.thread.author.pds, 72 73 record_uri=self.thread.uri, 73 - collection=lexicon.THREAD, 74 + collection=lexicon.POST, 74 75 attachments=self.thread.attachments, 75 76 ) 76 77 yield Static("", id="page-status-top", classes="page-status") ··· 91 92 self.query_one("#page-status-top", Static).update(text) 92 93 self.query_one("#page-status-bottom", Static).update(text) 93 94 95 + def _is_reply_widget(self, post: Post) -> bool: 96 + return post.record_uri is not None and post.record_uri != self.thread.uri 97 + 94 98 def _clear_replies(self) -> None: 95 99 for post in self.query(Post): 96 - if post.collection == lexicon.REPLY: 100 + if self._is_reply_widget(post): 97 101 post.remove() 98 102 99 103 @work(exclusive=True) ··· 120 124 for reply in result.replies: 121 125 self._replies_map[reply.uri] = reply 122 126 123 - # Fetch any quoted replies not already known (in parallel) 124 - missing = [ 125 - reply.quote 127 + # Fetch any parent replies not already known (in parallel) 128 + missing_parents = [ 129 + reply.parent 126 130 for reply in result.replies 127 - if reply.quote and reply.quote not in self._replies_map 131 + if reply.parent and reply.parent not in self._replies_map 128 132 ] 129 133 130 - async def fetch_quote(uri: str): 134 + async def fetch_parent(uri: str): 131 135 parsed = AtUri.parse(uri) 132 136 record, author = await asyncio.gather( 133 137 get_record(client, parsed.did, parsed.collection, parsed.rkey), 134 138 resolve_identity(client, parsed.did), 135 139 ) 136 - return uri, reply_from_record(record, author) 140 + return uri, post_from_record(record, author) 137 141 138 - if missing: 139 - quote_results = await asyncio.gather( 140 - *[fetch_quote(uri) for uri in missing], 142 + if missing_parents: 143 + parent_results = await asyncio.gather( 144 + *[fetch_parent(uri) for uri in missing_parents], 141 145 return_exceptions=True, 142 146 ) 143 - for quote_result in quote_results: 144 - if isinstance(quote_result, tuple): 145 - self._replies_map[quote_result[0]] = quote_result[1] 147 + for parent_result in parent_results: 148 + if isinstance(parent_result, tuple): 149 + self._replies_map[parent_result[0]] = parent_result[1] 146 150 147 151 for reply in result.replies: 148 - quote_text = None 149 - if reply.quote and reply.quote in self._replies_map: 150 - quoted = self._replies_map[reply.quote] 151 - body_preview = quoted.body[:200] + ( 152 - "..." if len(quoted.body) > 200 else "" 152 + parent_preview = None 153 + if reply.parent and reply.parent in self._replies_map: 154 + parent_post = self._replies_map[reply.parent] 155 + body_preview = parent_post.body[:200] + ( 156 + "..." if len(parent_post.body) > 200 else "" 153 157 ) 154 - quote_text = f"{quoted.author.handle}: {body_preview}" 158 + parent_preview = f"{parent_post.author.handle}: {body_preview}" 155 159 156 160 await scroll.mount( 157 161 Post( ··· 161 165 author_did=reply.author.did, 162 166 author_pds=reply.author.pds, 163 167 record_uri=reply.uri, 164 - collection=lexicon.REPLY, 168 + collection=lexicon.POST, 165 169 attachments=reply.attachments, 166 - quote_text=quote_text, 170 + parent_preview=parent_preview, 167 171 ), 168 172 before=self.query_one("#page-status-bottom"), 169 173 ) 170 174 171 175 # Focus first reply 172 176 replies = [ 173 - post for post in self.query(Post) if post.collection == lexicon.REPLY 177 + post for post in self.query(Post) if self._is_reply_widget(post) 174 178 ] 175 179 if replies: 176 180 replies[0].focus() ··· 243 247 if not session: 244 248 return 245 249 246 - # If focused on a reply, quote it 247 - quote = None 250 + # If focused on a reply, set it as the parent 251 + parent = None 248 252 focused = self.focused 249 253 if ( 250 254 isinstance(focused, Post) 251 - and focused.collection == lexicon.REPLY 255 + and self._is_reply_widget(focused) 252 256 and focused.record_uri 253 257 ): 254 - quote = self._replies_map.get(focused.record_uri) 258 + parent = self._replies_map.get(focused.record_uri) 255 259 256 260 self.app.push_screen( 257 - ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote) 261 + ComposeReplyScreen(self.bbs, self.handle, self.thread, parent=parent) 258 262 ) 259 263 260 264 def action_delete(self) -> None: ··· 278 282 await delete_record( 279 283 self.app.http_client, 280 284 session, 281 - post.collection, 285 + lexicon.POST, 282 286 post.rkey, 283 287 ) 284 288 except AuthError: ··· 288 292 self.notify("Failed to delete.", severity="error") 289 293 return 290 294 291 - if post.collection == lexicon.THREAD: 295 + if post.record_uri == self.thread.uri: 292 296 self.app.pop_screen() 293 297 else: 294 298 await post.remove()
+1 -4
tui/util.py
··· 4 4 5 5 6 6 def require_session(screen) -> dict | None: 7 - """Return the user session if logged in and not banned, else notify and return None.""" 7 + """Return the user session if logged in, else notify and return None.""" 8 8 session = screen.app.user_session 9 9 if not session: 10 10 screen.notify("You must be logged in to do that.", severity="error") 11 - return None 12 - if screen.bbs.site.is_banned(session["did"]): 13 - screen.notify("You have been banned from this BBS.", severity="error") 14 11 return None 15 12 return session 16 13
+5 -5
tui/widgets/post.py
··· 66 66 color: #8a8a8a; 67 67 margin-top: 1; 68 68 } 69 - Post .post-quote { 69 + Post .post-parent { 70 70 color: #8a8a8a; 71 71 border-left: solid #525252; 72 72 padding-left: 2; ··· 85 85 record_uri: str | None = None, 86 86 collection: str | None = None, 87 87 attachments: list[dict] | None = None, 88 - quote_text: str | None = None, 88 + parent_preview: str | None = None, 89 89 **kwargs, 90 90 ) -> None: 91 91 super().__init__(**kwargs) ··· 98 98 self.record_uri = record_uri 99 99 self.collection = collection 100 100 self.attachments = attachments or [] 101 - self._quote_text = quote_text 101 + self._parent_preview = parent_preview 102 102 103 103 @property 104 104 def rkey(self) -> str | None: ··· 110 110 yield Static(f"{self._author} {self._date}", classes="post-meta", markup=False) 111 111 if self._title: 112 112 yield Static(self._title, classes="post-title", markup=False) 113 - if self._quote_text: 114 - yield Markdown(self._quote_text, classes="post-quote") 113 + if self._parent_preview: 114 + yield Markdown(self._parent_preview, classes="post-parent") 115 115 yield Markdown(self._body, classes="post-body") 116 116 for attachment in self.attachments: 117 117 name = attachment.get("name", "file")
+1 -1
web/docker-entrypoint.sh
··· 6 6 # Strip trailing slash. 7 7 PUBLIC_URL="${PUBLIC_URL%/}" 8 8 9 - 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" 9 + 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" 10 10 11 11 # Runtime config read by the SPA at startup. 12 12 cat > /usr/share/nginx/html/config.json <<EOF
+2 -2
web/src/components/ActivityList.tsx
··· 33 33 > 34 34 <PostMeta handle={item.handle} createdAt={item.createdAt} /> 35 35 <p className="text-xs text-neutral-400 mb-1"> 36 - {item.type === "quote" 37 - ? "quoted your reply" 36 + {item.type === "parent_reply" 37 + ? "replied to your reply" 38 38 : `on: ${item.threadTitle}`} 39 39 </p> 40 40 <div className="line-clamp-2">
+8 -8
web/src/components/form/ComposeForm.tsx
··· 17 17 titleMaxLength?: number; 18 18 files: File[]; 19 19 onFilesChange: (files: File[]) => void; 20 - quote?: { uri: string; handle: string } | null; 21 - onClearQuote?: () => void; 20 + replyingTo?: { uri: string; handle: string } | null; 21 + onClearReplyTo?: () => void; 22 22 submitLabel?: string; 23 23 posting?: boolean; 24 24 className?: string; ··· 35 35 titlePlaceholder = "Title", 36 36 files, 37 37 onFilesChange, 38 - quote, 39 - onClearQuote, 38 + replyingTo, 39 + onClearReplyTo, 40 40 bodyMaxLength, 41 41 titleMaxLength, 42 42 submitLabel = "post", ··· 60 60 61 61 return ( 62 62 <form onSubmit={onSubmit} className={`space-y-3 ${className}`}> 63 - {quote && onClearQuote && ( 63 + {replyingTo && onClearReplyTo && ( 64 64 <div className="text-xs text-neutral-400"> 65 - <span>quoting {quote.handle}</span> 65 + <span>replying to {replyingTo.handle}</span> 66 66 <button 67 67 type="button" 68 - onClick={onClearQuote} 69 - aria-label="Clear quote" 68 + onClick={onClearReplyTo} 69 + aria-label="Clear reply" 70 70 className="text-neutral-400 hover:text-red-400 ml-2" 71 71 > 72 72
+2 -2
web/src/components/post/NewsCard.tsx
··· 1 - import type { News } from "../../lib/bbs"; 1 + import type { NewsPost } from "../../lib/bbs"; 2 2 import AttachmentLink from "./AttachmentLink"; 3 3 import PostActions from "./PostActions"; 4 4 import PostBody from "./PostBody"; 5 5 import PostMeta from "./PostMeta"; 6 6 7 7 interface NewsCardProps { 8 - news: News; 8 + news: NewsPost; 9 9 handle: string; 10 10 pds: string; 11 11 did: string;
+8 -8
web/src/components/post/PostActions.tsx
··· 1 1 import { useRef, useState, useEffect } from "react"; 2 - import { Quote, MoreHorizontal, Trash2, Ban, EyeOff } from "lucide-react"; 2 + import { Reply, MoreHorizontal, Trash2, Ban, EyeOff } from "lucide-react"; 3 3 4 4 interface PostActionsProps { 5 5 isAuthor: boolean; ··· 7 7 onDelete?: () => void; 8 8 onBan?: () => void; 9 9 onHide?: () => void; 10 - onQuote?: () => void; 10 + onReplyTo?: () => void; 11 11 } 12 12 13 13 export default function PostActions({ ··· 16 16 onDelete, 17 17 onBan, 18 18 onHide, 19 - onQuote, 19 + onReplyTo, 20 20 }: PostActionsProps) { 21 21 const [open, setOpen] = useState(false); 22 22 const menuRef = useRef<HTMLDivElement>(null); ··· 37 37 const canHide = isSysop && !!onHide; 38 38 const hasModActions = canDelete || canBan || canHide; 39 39 40 - if (!onQuote && !hasModActions) return null; 40 + if (!onReplyTo && !hasModActions) return null; 41 41 42 42 function select(action: () => void) { 43 43 setOpen(false); ··· 59 59 60 60 {open && ( 61 61 <div className="absolute right-0 mt-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg z-10 py-1 min-w-28"> 62 - {onQuote && ( 63 - <button onClick={() => select(onQuote)} className={menuItem}> 64 - <Quote size={12} /> quote 62 + {onReplyTo && ( 63 + <button onClick={() => select(onReplyTo)} className={menuItem}> 64 + <Reply size={12} /> reply 65 65 </button> 66 66 )} 67 67 68 - {onQuote && hasModActions && ( 68 + {onReplyTo && hasModActions && ( 69 69 <div className="border-t border-neutral-800 my-1" /> 70 70 )} 71 71
+13 -13
web/src/components/post/ReplyCard.tsx
··· 11 11 pds: string; 12 12 body: string; 13 13 createdAt: string; 14 - quote: string | null; 14 + parent: string | null; 15 15 attachments: { file: { ref: { $link: string } }; name: string }[]; 16 16 } 17 17 ··· 19 19 reply: Reply; 20 20 userDid: string; 21 21 sysopDid: string; 22 - quoted?: Reply; 23 - onQuote: () => void; 24 - onQuoteClick?: () => void; 22 + parentPost?: Reply; 23 + onReplyTo: () => void; 24 + onParentClick?: () => void; 25 25 onDelete: () => void; 26 26 onBan: () => void; 27 27 onHide: () => void; ··· 31 31 reply, 32 32 userDid, 33 33 sysopDid, 34 - quoted, 35 - onQuote, 36 - onQuoteClick, 34 + parentPost, 35 + onReplyTo, 36 + onParentClick, 37 37 onDelete, 38 38 onBan, 39 39 onHide, ··· 51 51 <PostActions 52 52 isAuthor={isAuthor} 53 53 isSysop={isSysop} 54 - onQuote={userDid ? onQuote : undefined} 54 + onReplyTo={userDid ? onReplyTo : undefined} 55 55 onDelete={onDelete} 56 56 onBan={onBan} 57 57 onHide={onHide} 58 58 /> 59 59 </div> 60 60 61 - {quoted && ( 61 + {parentPost && ( 62 62 <button 63 63 type="button" 64 - onClick={onQuoteClick} 64 + onClick={onParentClick} 65 65 className="block w-full text-left border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-400 hover:border-neutral-500 cursor-pointer" 66 66 > 67 - <span className="text-neutral-400">{quoted.handle}:</span>{" "} 67 + <span className="text-neutral-400">{parentPost.handle}:</span>{" "} 68 68 <PostBody> 69 - {quoted.body.substring(0, 200) + 70 - (quoted.body.length > 200 ? "..." : "")} 69 + {parentPost.body.substring(0, 200) + 70 + (parentPost.body.length > 200 ? "..." : "")} 71 71 </PostBody> 72 72 </button> 73 73 )}
+14 -20
web/src/hooks/useThreadReplies.ts
··· 116 116 const [replies, setReplies] = useState<Reply[]>([]); 117 117 const [loading, setLoading] = useState(true); 118 118 119 - // All replies we've ever seen — accumulates across page changes so quotes 120 - // and scroll targets always resolve, even for off-page replies. 119 + // All replies we've ever seen — accumulates across page changes so parent 120 + // previews and scroll targets always resolve, even for off-page replies. 121 121 const [replyCache, setReplyCache] = useState<Record<string, Reply>>({}); 122 122 123 123 // Pending scroll target — set when navigating to a reply on another page. ··· 142 142 // Fetch records from Slingshot. 143 143 const records = await getRecordsBatch(slice); 144 144 145 - // Drop moderated content. 146 - const visible = records.filter((r) => { 147 - const { did } = parseAtUri(r.uri); 148 - return ( 149 - !bbs.site.bannedDids.has(did) && !bbs.site.hiddenPosts.has(r.uri) 150 - ); 151 - }); 145 + const visible = records; 152 146 153 147 // Resolve author handles and build Reply objects. 154 148 const dids = visible.map((r) => parseAtUri(r.uri).did); ··· 174 168 const newCache: Record<string, Reply> = {}; 175 169 for (const item of items) newCache[item.uri] = item; 176 170 177 - // Fetch any quoted replies not already known 178 - const missingQuotes = items 179 - .filter((i) => i.quote && !newCache[i.quote!]) 180 - .map((i) => i.quote!) 171 + // Fetch any parent replies not already known 172 + const missingParents = items 173 + .filter((item) => item.parent && !newCache[item.parent!]) 174 + .map((item) => item.parent!) 181 175 .filter((uri) => !replyCache[uri]); 182 - if (missingQuotes.length) { 183 - const quoteRefs = [...new Set(missingQuotes)].map((uri) => 176 + if (missingParents.length) { 177 + const parentRefs = [...new Set(missingParents)].map((uri) => 184 178 parseAtUri(uri), 185 179 ); 186 - const quoteRecords = await getRecordsBatch(quoteRefs); 187 - const quoteDids = quoteRecords.map((r) => parseAtUri(r.uri).did); 188 - const quoteAuthors = await resolveIdentitiesBatch(quoteDids); 189 - for (const record of quoteRecords) { 190 - const reply = recordToReply(record, quoteAuthors); 180 + const parentRecords = await getRecordsBatch(parentRefs); 181 + const parentDids = parentRecords.map((record) => parseAtUri(record.uri).did); 182 + const parentAuthors = await resolveIdentitiesBatch(parentDids); 183 + for (const record of parentRecords) { 184 + const reply = recordToReply(record, parentAuthors); 191 185 if (reply) newCache[reply.uri] = reply; 192 186 } 193 187 }
+7 -9
web/src/lexicons/index.ts
··· 1 - export * as XyzAtboardsBan from "./types/xyz/atboards/ban.js"; 2 - export * as XyzAtboardsBoard from "./types/xyz/atboards/board.js"; 3 - export * as XyzAtboardsHide from "./types/xyz/atboards/hide.js"; 4 - export * as XyzAtboardsNews from "./types/xyz/atboards/news.js"; 5 - export * as XyzAtboardsPin from "./types/xyz/atboards/pin.js"; 6 - export * as XyzAtboardsProfile from "./types/xyz/atboards/profile.js"; 7 - export * as XyzAtboardsReply from "./types/xyz/atboards/reply.js"; 8 - export * as XyzAtboardsSite from "./types/xyz/atboards/site.js"; 9 - export * as XyzAtboardsThread from "./types/xyz/atboards/thread.js"; 1 + export * as XyzAtbbsBan from "./types/xyz/atbbs/ban.js"; 2 + export * as XyzAtbbsBoard from "./types/xyz/atbbs/board.js"; 3 + export * as XyzAtbbsHide from "./types/xyz/atbbs/hide.js"; 4 + export * as XyzAtbbsPin from "./types/xyz/atbbs/pin.js"; 5 + export * as XyzAtbbsPost from "./types/xyz/atbbs/post.js"; 6 + export * as XyzAtbbsProfile from "./types/xyz/atbbs/profile.js"; 7 + export * as XyzAtbbsSite from "./types/xyz/atbbs/site.js";
+2 -2
web/src/lexicons/types/xyz/atboards/ban.ts web/src/lexicons/types/xyz/atbbs/ban.ts
··· 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 6 /*#__PURE__*/ v.tidString(), 7 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("xyz.atboards.ban"), 8 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.ban"), 9 9 createdAt: /*#__PURE__*/ v.datetimeString(), 10 10 did: /*#__PURE__*/ v.didString(), 11 11 }), ··· 21 21 22 22 declare module "@atcute/lexicons/ambient" { 23 23 interface Records { 24 - "xyz.atboards.ban": mainSchema; 24 + "xyz.atbbs.ban": mainSchema; 25 25 } 26 26 }
+2 -2
web/src/lexicons/types/xyz/atboards/board.ts web/src/lexicons/types/xyz/atbbs/board.ts
··· 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 6 /*#__PURE__*/ v.string(), 7 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("xyz.atboards.board"), 8 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.board"), 9 9 createdAt: /*#__PURE__*/ v.datetimeString(), 10 10 /** 11 11 * @maxLength 500 ··· 33 33 34 34 declare module "@atcute/lexicons/ambient" { 35 35 interface Records { 36 - "xyz.atboards.board": mainSchema; 36 + "xyz.atbbs.board": mainSchema; 37 37 } 38 38 }
+2 -2
web/src/lexicons/types/xyz/atboards/hide.ts web/src/lexicons/types/xyz/atbbs/hide.ts
··· 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 6 /*#__PURE__*/ v.tidString(), 7 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("xyz.atboards.hide"), 8 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.hide"), 9 9 createdAt: /*#__PURE__*/ v.datetimeString(), 10 10 uri: /*#__PURE__*/ v.resourceUriString(), 11 11 }), ··· 21 21 22 22 declare module "@atcute/lexicons/ambient" { 23 23 interface Records { 24 - "xyz.atboards.hide": mainSchema; 24 + "xyz.atbbs.hide": mainSchema; 25 25 } 26 26 }
-68
web/src/lexicons/types/xyz/atboards/news.ts
··· 1 - import type {} from "@atcute/lexicons"; 2 - import * as v from "@atcute/lexicons/validations"; 3 - import type {} from "@atcute/lexicons/ambient"; 4 - 5 - const _attachmentSchema = /*#__PURE__*/ v.object({ 6 - $type: /*#__PURE__*/ v.optional( 7 - /*#__PURE__*/ v.literal("xyz.atboards.news#attachment"), 8 - ), 9 - /** 10 - * @accept *\/* 11 - * @maxSize 1000000 12 - */ 13 - file: /*#__PURE__*/ v.blob(), 14 - /** 15 - * @maxLength 256 16 - */ 17 - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 18 - /*#__PURE__*/ v.stringLength(0, 256), 19 - ]), 20 - }); 21 - const _mainSchema = /*#__PURE__*/ v.record( 22 - /*#__PURE__*/ v.tidString(), 23 - /*#__PURE__*/ v.object({ 24 - $type: /*#__PURE__*/ v.literal("xyz.atboards.news"), 25 - /** 26 - * @maxLength 10 27 - */ 28 - get attachments() { 29 - return /*#__PURE__*/ v.optional( 30 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(attachmentSchema), [ 31 - /*#__PURE__*/ v.arrayLength(0, 10), 32 - ]), 33 - ); 34 - }, 35 - /** 36 - * @maxLength 10000 37 - */ 38 - body: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 39 - /*#__PURE__*/ v.stringLength(0, 10000), 40 - ]), 41 - createdAt: /*#__PURE__*/ v.datetimeString(), 42 - site: /*#__PURE__*/ v.resourceUriString(), 43 - /** 44 - * @maxLength 300 45 - */ 46 - title: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 47 - /*#__PURE__*/ v.stringLength(0, 300), 48 - ]), 49 - }), 50 - ); 51 - 52 - type attachment$schematype = typeof _attachmentSchema; 53 - type main$schematype = typeof _mainSchema; 54 - 55 - export interface attachmentSchema extends attachment$schematype {} 56 - export interface mainSchema extends main$schematype {} 57 - 58 - export const attachmentSchema = _attachmentSchema as attachmentSchema; 59 - export const mainSchema = _mainSchema as mainSchema; 60 - 61 - export interface Attachment extends v.InferInput<typeof attachmentSchema> {} 62 - export interface Main extends v.InferInput<typeof mainSchema> {} 63 - 64 - declare module "@atcute/lexicons/ambient" { 65 - interface Records { 66 - "xyz.atboards.news": mainSchema; 67 - } 68 - }
+3 -3
web/src/lexicons/types/xyz/atboards/pin.ts web/src/lexicons/types/xyz/atbbs/pin.ts
··· 3 3 import type {} from "@atcute/lexicons/ambient"; 4 4 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 - /*#__PURE__*/ v.tidString(), 6 + /*#__PURE__*/ v.string(), 7 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("xyz.atboards.pin"), 8 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.pin"), 9 9 createdAt: /*#__PURE__*/ v.datetimeString(), 10 10 did: /*#__PURE__*/ v.didString(), 11 11 }), ··· 21 21 22 22 declare module "@atcute/lexicons/ambient" { 23 23 interface Records { 24 - "xyz.atboards.pin": mainSchema; 24 + "xyz.atbbs.pin": mainSchema; 25 25 } 26 26 }
+2 -2
web/src/lexicons/types/xyz/atboards/profile.ts web/src/lexicons/types/xyz/atbbs/profile.ts
··· 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 6 /*#__PURE__*/ v.literal("self"), 7 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("xyz.atboards.profile"), 8 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.profile"), 9 9 /** 10 10 * @maxLength 1000 11 11 */ ··· 44 44 45 45 declare module "@atcute/lexicons/ambient" { 46 46 interface Records { 47 - "xyz.atboards.profile": mainSchema; 47 + "xyz.atbbs.profile": mainSchema; 48 48 } 49 49 }
-64
web/src/lexicons/types/xyz/atboards/reply.ts
··· 1 - import type {} from "@atcute/lexicons"; 2 - import * as v from "@atcute/lexicons/validations"; 3 - import type {} from "@atcute/lexicons/ambient"; 4 - 5 - const _attachmentSchema = /*#__PURE__*/ v.object({ 6 - $type: /*#__PURE__*/ v.optional( 7 - /*#__PURE__*/ v.literal("xyz.atboards.reply#attachment"), 8 - ), 9 - /** 10 - * @accept *\/* 11 - * @maxSize 1000000 12 - */ 13 - file: /*#__PURE__*/ v.blob(), 14 - /** 15 - * @maxLength 256 16 - */ 17 - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 18 - /*#__PURE__*/ v.stringLength(0, 256), 19 - ]), 20 - }); 21 - const _mainSchema = /*#__PURE__*/ v.record( 22 - /*#__PURE__*/ v.tidString(), 23 - /*#__PURE__*/ v.object({ 24 - $type: /*#__PURE__*/ v.literal("xyz.atboards.reply"), 25 - /** 26 - * @maxLength 10 27 - */ 28 - get attachments() { 29 - return /*#__PURE__*/ v.optional( 30 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(attachmentSchema), [ 31 - /*#__PURE__*/ v.arrayLength(0, 10), 32 - ]), 33 - ); 34 - }, 35 - /** 36 - * @maxLength 10000 37 - */ 38 - body: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 39 - /*#__PURE__*/ v.stringLength(0, 10000), 40 - ]), 41 - createdAt: /*#__PURE__*/ v.datetimeString(), 42 - quote: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 43 - subject: /*#__PURE__*/ v.resourceUriString(), 44 - updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 45 - }), 46 - ); 47 - 48 - type attachment$schematype = typeof _attachmentSchema; 49 - type main$schematype = typeof _mainSchema; 50 - 51 - export interface attachmentSchema extends attachment$schematype {} 52 - export interface mainSchema extends main$schematype {} 53 - 54 - export const attachmentSchema = _attachmentSchema as attachmentSchema; 55 - export const mainSchema = _mainSchema as mainSchema; 56 - 57 - export interface Attachment extends v.InferInput<typeof attachmentSchema> {} 58 - export interface Main extends v.InferInput<typeof mainSchema> {} 59 - 60 - declare module "@atcute/lexicons/ambient" { 61 - interface Records { 62 - "xyz.atboards.reply": mainSchema; 63 - } 64 - }
+3 -9
web/src/lexicons/types/xyz/atboards/site.ts web/src/lexicons/types/xyz/atbbs/site.ts
··· 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 6 /*#__PURE__*/ v.literal("self"), 7 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("xyz.atboards.site"), 9 - bannedDids: /*#__PURE__*/ v.optional( 10 - /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 11 - ), 12 - boards: /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 8 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.site"), 9 + boards: /*#__PURE__*/ v.array(/*#__PURE__*/ v.resourceUriString()), 13 10 createdAt: /*#__PURE__*/ v.datetimeString(), 14 11 /** 15 12 * @maxLength 1000 ··· 17 14 description: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 18 15 /*#__PURE__*/ v.stringLength(0, 1000), 19 16 ]), 20 - hiddenPosts: /*#__PURE__*/ v.optional( 21 - /*#__PURE__*/ v.array(/*#__PURE__*/ v.resourceUriString()), 22 - ), 23 17 /** 24 18 * @maxLength 5000 25 19 */ ··· 46 40 47 41 declare module "@atcute/lexicons/ambient" { 48 42 interface Records { 49 - "xyz.atboards.site": mainSchema; 43 + "xyz.atbbs.site": mainSchema; 50 44 } 51 45 }
+14 -9
web/src/lexicons/types/xyz/atboards/thread.ts web/src/lexicons/types/xyz/atbbs/post.ts
··· 4 4 5 5 const _attachmentSchema = /*#__PURE__*/ v.object({ 6 6 $type: /*#__PURE__*/ v.optional( 7 - /*#__PURE__*/ v.literal("xyz.atboards.thread#attachment"), 7 + /*#__PURE__*/ v.literal("xyz.atbbs.post#attachment"), 8 8 ), 9 9 /** 10 - * @accept *\/* 11 10 * @maxSize 1000000 12 11 */ 13 - file: /*#__PURE__*/ v.blob(), 12 + file: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ 13 + /*#__PURE__*/ v.blobSize(1000000), 14 + ]), 14 15 /** 15 16 * @maxLength 256 16 17 */ ··· 21 22 const _mainSchema = /*#__PURE__*/ v.record( 22 23 /*#__PURE__*/ v.tidString(), 23 24 /*#__PURE__*/ v.object({ 24 - $type: /*#__PURE__*/ v.literal("xyz.atboards.thread"), 25 + $type: /*#__PURE__*/ v.literal("xyz.atbbs.post"), 25 26 /** 26 27 * @maxLength 10 27 28 */ ··· 32 33 ]), 33 34 ); 34 35 }, 35 - board: /*#__PURE__*/ v.resourceUriString(), 36 36 /** 37 37 * @maxLength 10000 38 38 */ ··· 40 40 /*#__PURE__*/ v.stringLength(0, 10000), 41 41 ]), 42 42 createdAt: /*#__PURE__*/ v.datetimeString(), 43 + parent: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 44 + root: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 45 + scope: /*#__PURE__*/ v.resourceUriString(), 43 46 /** 44 47 * @maxLength 300 45 48 */ 46 - title: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 47 - /*#__PURE__*/ v.stringLength(0, 300), 48 - ]), 49 + title: /*#__PURE__*/ v.optional( 50 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 51 + /*#__PURE__*/ v.stringLength(0, 300), 52 + ]), 53 + ), 49 54 updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 50 55 }), 51 56 ); ··· 64 69 65 70 declare module "@atcute/lexicons/ambient" { 66 71 interface Records { 67 - "xyz.atboards.thread": mainSchema; 72 + "xyz.atbbs.post": mainSchema; 68 73 } 69 74 }
+26 -24
web/src/lib/activity.ts
··· 1 - /** Activity data — replies to your threads + quotes of your replies. */ 1 + /** Activity data — replies to your posts from other users. */ 2 2 3 3 import { fetchAndHydrate, listRecords } from "./atproto"; 4 - import { THREAD, REPLY } from "./lexicon"; 4 + import { POST } from "./lexicon"; 5 5 import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; 7 - import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; 8 - import type { XyzAtboardsThread, XyzAtboardsReply } from "../lexicons"; 6 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 7 + import type { XyzAtbbsPost } from "../lexicons"; 9 8 10 9 export interface ActivityItem { 11 - type: "reply" | "quote"; 10 + type: "reply" | "parent_reply"; 12 11 threadTitle: string; 13 12 threadUri: string; 14 13 replyUri: string; ··· 49 48 pdsUrl: string, 50 49 ): Promise<ActivityItem[]> { 51 50 const SCAN_LIMIT = 50; 52 - const [allThreads, allReplies] = await Promise.all([ 53 - listRecords(pdsUrl, did, THREAD, SCAN_LIMIT), 54 - listRecords(pdsUrl, did, REPLY, SCAN_LIMIT), 55 - ]); 56 - const threads = allThreads.filter((record) => is(threadSchema, record.value)); 57 - const replies = allReplies.filter((record) => is(replySchema, record.value)); 51 + const allPosts = await listRecords(pdsUrl, did, POST, SCAN_LIMIT); 52 + const validPosts = allPosts.filter((record) => is(postSchema, record.value)); 53 + 54 + const rootPosts = validPosts.filter( 55 + (record) => !(record.value as Record<string, unknown>).root, 56 + ); 57 + const replyPosts = validPosts.filter( 58 + (record) => !!(record.value as Record<string, unknown>).root, 59 + ); 58 60 59 61 const results = await Promise.all([ 60 - ...threads.map((thread) => { 61 - const value = thread.value as unknown as XyzAtboardsThread.Main; 62 + ...rootPosts.map((post) => { 63 + const value = post.value as unknown as XyzAtbbsPost.Main; 62 64 return fetchBacklinkItems( 63 - thread.uri, 64 - `${REPLY}:subject`, 65 + post.uri, 66 + `${POST}:root`, 65 67 did, 66 68 "reply", 67 69 value.title ?? "", 68 - thread.uri, 70 + post.uri, 69 71 ); 70 72 }), 71 - ...replies.map((reply) => { 72 - const value = reply.value as unknown as XyzAtboardsReply.Main; 73 + ...replyPosts.map((reply) => { 74 + const value = reply.value as unknown as XyzAtbbsPost.Main; 73 75 return fetchBacklinkItems( 74 76 reply.uri, 75 - `${REPLY}:quote`, 77 + `${POST}:parent`, 76 78 did, 77 - "quote", 79 + "parent_reply", 78 80 "", 79 - value.subject ?? "", 81 + value.root ?? "", 80 82 ); 81 83 }), 82 84 ]); 83 85 84 - // Deduplicate — prefer "quote" type when the same reply appears as both. 86 + // Deduplicate — prefer "parent-reply" type when the same reply appears as both. 85 87 const seen = new Map<string, ActivityItem>(); 86 88 for (const item of results.flat()) { 87 89 const key = item.handle + item.body + item.createdAt; 88 - if (!seen.has(key) || item.type === "quote") seen.set(key, item); 90 + if (!seen.has(key) || item.type === "parent_reply") seen.set(key, item); 89 91 } 90 92 return [...seen.values()].sort((a, b) => 91 93 b.createdAt.localeCompare(a.createdAt),
-4
web/src/lib/atproto.ts
··· 128 128 limit?: number; 129 129 cursor?: string; 130 130 excludeDid?: string; 131 - bannedDids?: Set<string>; 132 - hiddenPosts?: Set<string>; 133 131 }, 134 132 ): Promise<FetchAndHydrateResult> { 135 133 const limit = opts?.limit ?? 50; ··· 141 139 const filtered = records.filter((record) => { 142 140 const { did } = parseAtUri(record.uri); 143 141 if (opts?.excludeDid && did === opts.excludeDid) return false; 144 - if (opts?.bannedDids?.has(did)) return false; 145 - if (opts?.hiddenPosts?.has(record.uri)) return false; 146 142 return true; 147 143 }); 148 144
+39 -54
web/src/lib/bbs.ts
··· 5 5 getRecord, 6 6 getRecordsBatch, 7 7 getBacklinks, 8 - listRecords, 9 8 resolveIdentity, 10 9 type MiniDoc, 11 10 type ATRecord, 12 11 } from "./atproto"; 13 - import { SITE, BOARD, NEWS, BAN, HIDE } from "./lexicon"; 12 + import { SITE, BOARD, POST, BAN, HIDE } from "./lexicon"; 14 13 import { makeAtUri, parseAtUri } from "./util"; 15 14 import { is } from "@atcute/lexicons/validations"; 16 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; 17 - import { mainSchema as boardSchema } from "../lexicons/types/xyz/atboards/board"; 18 - import { mainSchema as newsSchema } from "../lexicons/types/xyz/atboards/news"; 19 - import { mainSchema as banSchema } from "../lexicons/types/xyz/atboards/ban"; 20 - import { mainSchema as hideSchema } from "../lexicons/types/xyz/atboards/hide"; 15 + import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 16 + import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; 17 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 21 18 import type { 22 - XyzAtboardsSite, 23 - XyzAtboardsBoard, 24 - XyzAtboardsNews, 25 - XyzAtboardsBan, 26 - XyzAtboardsHide, 19 + XyzAtbbsSite, 20 + XyzAtbbsBoard, 21 + XyzAtbbsPost, 27 22 } from "../lexicons"; 28 23 29 24 export class BBSNotFoundError extends Error {} ··· 38 33 updatedAt?: string; 39 34 } 40 35 41 - export interface NewsAttachment { 36 + export interface PostAttachment { 42 37 file: { ref: { $link: string } }; 43 38 name: string; 44 39 } 45 40 46 - export interface News { 47 - tid: string; 48 - siteUri: string; 41 + export interface NewsPost { 42 + uri: string; 43 + rkey: string; 49 44 title: string; 50 45 body: string; 51 46 createdAt: string; 52 - attachments?: NewsAttachment[]; 47 + attachments?: PostAttachment[]; 53 48 } 54 49 55 50 export interface Site { ··· 57 52 description: string; 58 53 intro: string; 59 54 boards: Board[]; 60 - bannedDids: Set<string>; 61 - hiddenPosts: Set<string>; 62 55 createdAt: string; 63 56 updatedAt?: string; 64 57 } ··· 66 59 export interface BBS { 67 60 identity: MiniDoc; 68 61 site: Site; 69 - news: News[]; 62 + news: NewsPost[]; 70 63 } 71 64 72 65 const bbsCache = new TTLCache<string, BBS>(5 * 60 * 1000); ··· 104 97 if (!is(siteSchema, siteRecord.value)) { 105 98 throw new NoBBSError(`${handle} has an invalid site record.`); 106 99 } 107 - const siteValue = siteRecord.value as unknown as XyzAtboardsSite.Main; 100 + const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; 108 101 const siteUri = makeAtUri(identity.did, SITE, "self"); 109 - const boardSlugs: string[] = siteValue.boards ?? []; 102 + const boardUris: string[] = siteValue.boards ?? []; 110 103 111 - const [boardResults, newsBacklinks, banRecords, hideRecords] = 112 - await Promise.all([ 113 - Promise.allSettled( 114 - boardSlugs.map((slug) => getRecord(identity.did, BOARD, slug)), 115 - ), 116 - getBacklinks(siteUri, `${NEWS}:site`, 50).catch(() => null), 117 - listRecords(identity.pds, identity.did, BAN).catch(() => []), 118 - listRecords(identity.pds, identity.did, HIDE).catch(() => []), 119 - ]); 104 + const [boardResults, newsBacklinks] = await Promise.all([ 105 + Promise.allSettled( 106 + boardUris.map((uri) => { 107 + const parsed = parseAtUri(uri); 108 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 109 + }), 110 + ), 111 + getBacklinks(siteUri, `${POST}:scope`, 50).catch(() => null), 112 + ]); 120 113 121 114 const boards: Board[] = []; 122 115 boardResults.forEach((result, index) => { 123 116 if (result.status !== "fulfilled") return; 124 117 if (!is(boardSchema, result.value.value)) return; 125 - const board = result.value.value as unknown as XyzAtboardsBoard.Main; 118 + const board = result.value.value as unknown as XyzAtbbsBoard.Main; 119 + const parsed = parseAtUri(boardUris[index]); 126 120 boards.push({ 127 - slug: boardSlugs[index], 121 + slug: parsed.rkey, 128 122 name: board.name, 129 123 description: board.description, 130 124 createdAt: board.createdAt, ··· 132 126 }); 133 127 }); 134 128 135 - // News - only sysop's repo 136 - let news: News[] = []; 129 + // News - posts scoped to the site, only sysop's repo 130 + let news: NewsPost[] = []; 137 131 if (newsBacklinks) { 138 132 const sysopRefs = newsBacklinks.records.filter( 139 133 (ref) => ref.did === identity.did, 140 134 ); 141 135 const newsRecords = await getRecordsBatch(sysopRefs); 142 136 news = newsRecords 143 - .filter((record) => is(newsSchema, record.value)) 137 + .filter((record) => is(postSchema, record.value)) 138 + .filter((record) => { 139 + const value = record.value as unknown as XyzAtbbsPost.Main; 140 + return value.title && !value.root; // root posts with titles are news/threads 141 + }) 144 142 .map((record) => { 145 - const value = record.value as unknown as XyzAtboardsNews.Main; 143 + const value = record.value as unknown as XyzAtbbsPost.Main; 146 144 return { 147 - tid: parseAtUri(record.uri).rkey, 148 - siteUri: value.site, 149 - title: value.title, 145 + uri: record.uri, 146 + rkey: parseAtUri(record.uri).rkey, 147 + title: value.title ?? "", 150 148 body: value.body, 151 149 createdAt: value.createdAt, 152 - attachments: value.attachments as NewsAttachment[] | undefined, 150 + attachments: value.attachments as PostAttachment[] | undefined, 153 151 }; 154 152 }); 155 153 news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 156 154 } 157 155 158 - const bannedDids = new Set( 159 - banRecords 160 - .filter((record) => is(banSchema, record.value)) 161 - .map((record) => (record.value as unknown as XyzAtboardsBan.Main).did), 162 - ); 163 - const hiddenPosts = new Set( 164 - hideRecords 165 - .filter((record) => is(hideSchema, record.value)) 166 - .map((record) => (record.value as unknown as XyzAtboardsHide.Main).uri), 167 - ); 168 - 169 156 return { 170 157 identity, 171 158 site: { ··· 173 160 description: siteValue.description, 174 161 intro: siteValue.intro, 175 162 boards, 176 - bannedDids, 177 - hiddenPosts, 178 163 createdAt: siteValue.createdAt ?? "", 179 164 updatedAt: siteValue.updatedAt, 180 165 },
+24 -14
web/src/lib/deletebbs.ts
··· 1 - /** Delete a user's entire BBS: boards, news, bans, hides, then the site record. */ 1 + /** Delete a user's entire BBS: boards, news posts, bans, hides, then the site record. */ 2 2 3 3 import type { Client } from "@atcute/client"; 4 - import { getRecord, listRecords } from "./atproto"; 5 - import { BAN, BOARD, HIDE, NEWS, SITE } from "./lexicon"; 6 - import { parseAtUri } from "./util"; 4 + import { getRecord, getBacklinks, listRecords } from "./atproto"; 5 + import { BAN, BOARD, HIDE, POST, SITE } from "./lexicon"; 6 + import { makeAtUri, parseAtUri } from "./util"; 7 7 import { deleteRecord } from "./writes"; 8 8 9 9 export async function deleteBBS(agent: Client, did: string, pdsUrl: string) { ··· 11 11 12 12 const existing = await getRecord(did, SITE, "self"); 13 13 const siteValue = existing.value as Record<string, unknown>; 14 - const boardSlugs: string[] = ( 14 + const boardUris: string[] = ( 15 15 Array.isArray(siteValue.boards) ? siteValue.boards : [] 16 16 ) as string[]; 17 17 18 - for (const slug of boardSlugs) { 18 + // Delete boards 19 + for (const uri of boardUris) { 19 20 try { 20 - await deleteRecord(agent, BOARD, slug); 21 + const { rkey } = parseAtUri(uri); 22 + await deleteRecord(agent, BOARD, rkey); 21 23 } catch { 22 - failed.push(`board/${slug}`); 24 + failed.push(`board/${uri}`); 23 25 } 24 26 } 25 27 26 - const newsRecords = await listRecords(pdsUrl, did, NEWS); 27 - for (const record of newsRecords) { 28 - try { 29 - await deleteRecord(agent, NEWS, parseAtUri(record.uri).rkey); 30 - } catch { 31 - failed.push(`news/${parseAtUri(record.uri).rkey}`); 28 + // Delete sysop's news posts (posts scoped to the site) 29 + const siteUri = makeAtUri(did, SITE, "self"); 30 + try { 31 + const backlinks = await getBacklinks(siteUri, `${POST}:scope`, 100); 32 + for (const ref of backlinks.records) { 33 + if (ref.did === did) { 34 + try { 35 + await deleteRecord(agent, POST, ref.rkey); 36 + } catch { 37 + failed.push(`post/${ref.rkey}`); 38 + } 39 + } 32 40 } 41 + } catch { 42 + failed.push("news lookup"); 33 43 } 34 44 35 45 for (const collection of [BAN, HIDE]) {
+7 -9
web/src/lib/lexicon.ts
··· 1 - export const SITE = "xyz.atboards.site"; 2 - export const BOARD = "xyz.atboards.board"; 3 - export const NEWS = "xyz.atboards.news"; 4 - export const THREAD = "xyz.atboards.thread"; 5 - export const REPLY = "xyz.atboards.reply"; 6 - export const BAN = "xyz.atboards.ban"; 7 - export const HIDE = "xyz.atboards.hide"; 8 - export const PIN = "xyz.atboards.pin"; 9 - export const PROFILE = "xyz.atboards.profile"; 1 + export const SITE = "xyz.atbbs.site"; 2 + export const BOARD = "xyz.atbbs.board"; 3 + export const POST = "xyz.atbbs.post"; 4 + export const BAN = "xyz.atbbs.ban"; 5 + export const HIDE = "xyz.atbbs.hide"; 6 + export const PIN = "xyz.atbbs.pin"; 7 + export const PROFILE = "xyz.atbbs.profile";
+3 -6
web/src/lib/limits.ts
··· 1 - /** Field length limits from the atboards lexicons. */ 1 + /** Field length limits from the atbbs lexicons. */ 2 2 3 3 export const SITE_NAME = 100; 4 4 export const SITE_DESCRIPTION = 1000; 5 5 export const SITE_INTRO = 5000; 6 6 export const BOARD_NAME = 100; 7 7 export const BOARD_DESCRIPTION = 500; 8 - export const THREAD_TITLE = 300; 9 - export const THREAD_BODY = 10000; 10 - export const NEWS_TITLE = 300; 11 - export const NEWS_BODY = 10000; 12 - export const REPLY_BODY = 10000; 8 + export const POST_TITLE = 300; 9 + export const POST_BODY = 10000; 13 10 export const ATTACHMENT_NAME = 256; 14 11 export const MAX_ATTACHMENTS = 10; 15 12 export const PROFILE_NAME = 100;
+19 -16
web/src/lib/mythreads.ts
··· 1 - /** Fetch the user's own threads across all BBSes. */ 1 + /** Fetch the user's own root posts (threads) across all BBSes. */ 2 2 3 3 import { listRecords, resolveIdentitiesBatch } from "./atproto"; 4 - import { THREAD } from "./lexicon"; 4 + import { POST } from "./lexicon"; 5 5 import { parseAtUri } from "./util"; 6 6 import { is } from "@atcute/lexicons/validations"; 7 - import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; 8 - import type { XyzAtboardsThread } from "../lexicons"; 7 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 8 + import type { XyzAtbbsPost } from "../lexicons"; 9 9 10 10 export interface MyThread { 11 11 uri: string; ··· 21 21 pdsUrl: string, 22 22 did: string, 23 23 ): Promise<MyThread[]> { 24 - const records = await listRecords(pdsUrl, did, THREAD); 25 - const threadRecords = records.filter((record) => 26 - is(threadSchema, record.value), 27 - ); 28 - if (!threadRecords.length) return []; 24 + const records = await listRecords(pdsUrl, did, POST); 25 + const rootPosts = records 26 + .filter((record) => is(postSchema, record.value)) 27 + .filter((record) => { 28 + const value = record.value as Record<string, unknown>; 29 + return !value.root && value.title; // root posts with titles = threads 30 + }); 31 + if (!rootPosts.length) return []; 29 32 30 33 const bbsDids = new Set( 31 - threadRecords.map((record) => { 32 - const value = record.value as unknown as XyzAtboardsThread.Main; 33 - return parseAtUri(value.board).did; 34 + rootPosts.map((record) => { 35 + const value = record.value as unknown as XyzAtbbsPost.Main; 36 + return parseAtUri(value.scope).did; 34 37 }), 35 38 ); 36 39 const identities = await resolveIdentitiesBatch([...bbsDids]); 37 40 38 41 const results: MyThread[] = []; 39 - for (const record of threadRecords) { 40 - const value = record.value as unknown as XyzAtboardsThread.Main; 41 - const bbsDid = parseAtUri(value.board).did; 42 + for (const record of rootPosts) { 43 + const value = record.value as unknown as XyzAtbbsPost.Main; 44 + const bbsDid = parseAtUri(value.scope).did; 42 45 const identity = identities[bbsDid]; 43 46 if (!identity) continue; 44 47 results.push({ 45 48 uri: record.uri, 46 49 rkey: parseAtUri(record.uri).rkey, 47 - title: value.title, 50 + title: value.title ?? "", 48 51 body: value.body, 49 52 createdAt: value.createdAt, 50 53 bbsDid,
+6 -6
web/src/lib/pins.ts
··· 3 3 import { listRecords, getRecord, resolveIdentitiesBatch } from "./atproto"; 4 4 import { PIN, SITE } from "./lexicon"; 5 5 import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as pinSchema } from "../lexicons/types/xyz/atboards/pin"; 7 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; 8 - import type { XyzAtboardsPin, XyzAtboardsSite } from "../lexicons"; 6 + import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; 7 + import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 8 + import type { XyzAtbbsPin, XyzAtbbsSite } from "../lexicons"; 9 9 import { parseAtUri } from "./util"; 10 10 11 11 export interface PinnedBBS { ··· 24 24 const pinRecords = records.filter((record) => is(pinSchema, record.value)); 25 25 26 26 const pinnedDids = pinRecords.map( 27 - (record) => (record.value as unknown as XyzAtboardsPin.Main).did, 27 + (record) => (record.value as unknown as XyzAtbbsPin.Main).did, 28 28 ); 29 29 if (!pinnedDids.length) return []; 30 30 ··· 37 37 siteResults.forEach((result, index) => { 38 38 if (result.status !== "fulfilled") return; 39 39 if (!is(siteSchema, result.value.value)) return; 40 - const siteValue = result.value.value as unknown as XyzAtboardsSite.Main; 40 + const siteValue = result.value.value as unknown as XyzAtbbsSite.Main; 41 41 siteNames[pinnedDids[index]] = siteValue.name; 42 42 }); 43 43 44 44 const results: PinnedBBS[] = []; 45 45 for (const record of pinRecords) { 46 - const value = record.value as unknown as XyzAtboardsPin.Main; 46 + const value = record.value as unknown as XyzAtbbsPin.Main; 47 47 const identity = identities[value.did]; 48 48 if (!identity) continue; 49 49 results.push({
+5 -5
web/src/lib/profile.ts
··· 3 3 import { getRecord, resolveIdentity } from "./atproto"; 4 4 import { PROFILE, SITE } from "./lexicon"; 5 5 import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as profileSchema } from "../lexicons/types/xyz/atboards/profile"; 7 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; 8 - import type { XyzAtboardsProfile, XyzAtboardsSite } from "../lexicons"; 6 + import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile"; 7 + import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 8 + import type { XyzAtbbsProfile, XyzAtbbsSite } from "../lexicons"; 9 9 10 10 export interface Profile { 11 11 did: string; ··· 43 43 is(profileSchema, profileResult.value.value) 44 44 ) { 45 45 const value = profileResult.value 46 - .value as unknown as XyzAtboardsProfile.Main; 46 + .value as unknown as XyzAtbbsProfile.Main; 47 47 profile.name = value.name; 48 48 profile.pronouns = value.pronouns; 49 49 profile.bio = value.bio; ··· 54 54 siteResult.status === "fulfilled" && 55 55 is(siteSchema, siteResult.value.value) 56 56 ) { 57 - const value = siteResult.value.value as unknown as XyzAtboardsSite.Main; 57 + const value = siteResult.value.value as unknown as XyzAtbbsSite.Main; 58 58 profile.bbsName = value.name; 59 59 profile.bbsDescription = value.description; 60 60 }
+5 -5
web/src/lib/replies.ts
··· 3 3 import { type BacklinkRef } from "./atproto"; 4 4 import { parseAtUri } from "./util"; 5 5 import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; 7 - import type { XyzAtboardsReply } from "../lexicons"; 6 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 7 + import type { XyzAtbbsPost } from "../lexicons"; 8 8 import type { Reply } from "../components/post/ReplyCard"; 9 9 10 10 export type { BacklinkRef }; ··· 49 49 ): Reply | null { 50 50 const { did, rkey } = parseAtUri(record.uri); 51 51 if (!(did in authors)) return null; 52 - if (!is(replySchema, record.value)) return null; 53 - const value = record.value as unknown as XyzAtboardsReply.Main; 52 + if (!is(postSchema, record.value)) return null; 53 + const value = record.value as unknown as XyzAtbbsPost.Main; 54 54 return { 55 55 uri: record.uri, 56 56 did, ··· 59 59 pds: authors[did].pds ?? "", 60 60 body: value.body, 61 61 createdAt: value.createdAt, 62 - quote: value.quote ?? null, 62 + parent: value.parent ?? null, 63 63 attachments: (value.attachments ?? []) as Reply["attachments"], 64 64 }; 65 65 }
+4 -2
web/src/lib/util.ts
··· 29 29 return { did: parts[2], collection: parts[3], rkey: parts[4] }; 30 30 } 31 31 32 + import type { Did } from "@atcute/lexicons/syntax"; 33 + 32 34 export function makeAtUri( 33 35 did: string, 34 36 collection: string, 35 37 rkey: string, 36 - ): string { 37 - return `at://${did}/${collection}/${rkey}`; 38 + ): `at://${Did}/${string}/${string}` { 39 + return `at://${did as Did}/${collection}/${rkey}`; 38 40 }
+36 -79
web/src/lib/writes.ts
··· 1 1 /** Authenticated PDS write helpers using an atcute Client from useAuth().agent. */ 2 2 3 3 import type { Client } from "@atcute/client"; 4 - import { 5 - SITE, 6 - BOARD, 7 - NEWS, 8 - THREAD, 9 - REPLY, 10 - BAN, 11 - HIDE, 12 - PIN, 13 - PROFILE, 14 - } from "./lexicon"; 4 + import { SITE, BOARD, POST, BAN, HIDE, PIN, PROFILE } from "./lexicon"; 15 5 import { invalidateBBSCache } from "./bbs"; 16 6 import { nowIso } from "./util"; 17 7 import { getCurrentUser } from "./auth"; 18 8 import type { 19 - XyzAtboardsThread, 20 - XyzAtboardsReply, 21 - XyzAtboardsSite, 22 - XyzAtboardsBoard, 23 - XyzAtboardsNews, 24 - XyzAtboardsBan, 25 - XyzAtboardsHide, 26 - XyzAtboardsPin, 27 - XyzAtboardsProfile, 9 + XyzAtbbsPost, 10 + XyzAtbbsSite, 11 + XyzAtbbsBoard, 12 + XyzAtbbsBan, 13 + XyzAtbbsHide, 14 + XyzAtbbsPin, 15 + XyzAtbbsProfile, 28 16 } from "../lexicons"; 29 17 30 18 // --- Lexicon value types --- 31 19 32 - // Strip $type so a single Attachment value works for both thread and reply. 33 - type Attachment = Omit<XyzAtboardsThread.Attachment, "$type">; 20 + // Strip $type so a single Attachment value works for posts. 21 + type Attachment = Omit<XyzAtbbsPost.Attachment, "$type">; 34 22 35 - type ThreadValue = Omit<XyzAtboardsThread.Main, "$type">; 36 - type ReplyValue = Omit<XyzAtboardsReply.Main, "$type">; 37 - type SiteValue = Omit<XyzAtboardsSite.Main, "$type">; 38 - type BoardValue = Omit<XyzAtboardsBoard.Main, "$type">; 39 - type NewsValue = Omit<XyzAtboardsNews.Main, "$type">; 40 - type BanValue = Omit<XyzAtboardsBan.Main, "$type">; 41 - type HideValue = Omit<XyzAtboardsHide.Main, "$type">; 42 - type PinValue = Omit<XyzAtboardsPin.Main, "$type">; 43 - type ProfileValue = Omit<XyzAtboardsProfile.Main, "$type">; 23 + type PostValue = Omit<XyzAtbbsPost.Main, "$type">; 24 + type SiteValue = Omit<XyzAtbbsSite.Main, "$type">; 25 + type BoardValue = Omit<XyzAtbbsBoard.Main, "$type">; 26 + type BanValue = Omit<XyzAtbbsBan.Main, "$type">; 27 + type HideValue = Omit<XyzAtbbsHide.Main, "$type">; 28 + type PinValue = Omit<XyzAtbbsPin.Main, "$type">; 29 + type ProfileValue = Omit<XyzAtbbsProfile.Main, "$type">; 44 30 45 31 interface BlobRef { 46 32 $type: "blob"; ··· 164 150 return out; 165 151 } 166 152 167 - // --- Threads & replies --- 153 + // --- Posts (threads, replies, news) --- 168 154 169 - export async function createThread( 155 + export async function createPost( 170 156 rpc: Client, 171 - boardUri: string, 172 - title: string, 157 + scope: string, 173 158 body: string, 174 - attachments?: Attachment[], 159 + opts?: { 160 + title?: string; 161 + root?: string; 162 + parent?: string; 163 + attachments?: Attachment[]; 164 + }, 175 165 ) { 176 - const value: ThreadValue = { 177 - board: boardUri as ThreadValue["board"], 178 - title, 166 + const value: PostValue = { 167 + scope: scope as PostValue["scope"], 179 168 body, 180 169 createdAt: nowIso(), 181 - ...(attachments?.length ? { attachments } : {}), 170 + ...(opts?.title ? { title: opts.title } : {}), 171 + ...(opts?.root ? { root: opts.root as PostValue["root"] } : {}), 172 + ...(opts?.parent ? { parent: opts.parent as PostValue["parent"] } : {}), 173 + ...(opts?.attachments?.length ? { attachments: opts.attachments } : {}), 182 174 }; 183 - return createRecord(rpc, THREAD, value); 175 + return createRecord(rpc, POST, value); 184 176 } 185 177 186 - export async function createReply( 187 - rpc: Client, 188 - threadUri: string, 189 - body: string, 190 - quote?: string | null, 191 - attachments?: Attachment[], 192 - ) { 193 - const value: ReplyValue = { 194 - subject: threadUri as ReplyValue["subject"], 195 - body, 196 - createdAt: nowIso(), 197 - ...(quote ? { quote: quote as ReplyValue["quote"] } : {}), 198 - ...(attachments?.length ? { attachments } : {}), 199 - }; 200 - return createRecord(rpc, REPLY, value); 201 - } 202 - 203 - // --- Sysop: site, board, news --- 178 + // --- Sysop: site, board --- 204 179 205 180 export async function putSite(rpc: Client, site: SiteValue) { 206 181 const resp = await putRecord(rpc, SITE, "self", site); ··· 225 200 return resp; 226 201 } 227 202 228 - export async function createNews( 229 - rpc: Client, 230 - siteUri: string, 231 - title: string, 232 - body: string, 233 - attachments?: Attachment[], 234 - ) { 235 - const value: NewsValue = { 236 - site: siteUri as NewsValue["site"], 237 - title, 238 - body, 239 - createdAt: nowIso(), 240 - ...(attachments?.length 241 - ? { attachments: attachments as NewsValue["attachments"] } 242 - : {}), 243 - }; 244 - return createRecord(rpc, NEWS, value); 245 - } 246 - 247 203 // --- Sysop: bans & hides --- 248 204 249 205 export async function createBan(rpc: Client, did: string) { ··· 273 229 did: did as PinValue["did"], 274 230 createdAt: nowIso(), 275 231 }; 276 - return createRecord(rpc, PIN, value); 232 + // Use DID as rkey for idempotent pins 233 + return createRecord(rpc, PIN, value, did); 277 234 } 278 235 279 236 // --- Profiles ---
+23 -25
web/src/pages/BBS.tsx
··· 2 2 import { Link, useRouteLoaderData } from "react-router-dom"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 5 - import { createNews, deleteRecord, uploadAttachments } from "../lib/writes"; 5 + import { createPost, deleteRecord, uploadAttachments } from "../lib/writes"; 6 6 import ComposeForm from "../components/form/ComposeForm"; 7 - import { NEWS, SITE } from "../lib/lexicon"; 7 + import { POST, SITE } from "../lib/lexicon"; 8 8 import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; 9 9 import * as limits from "../lib/limits"; 10 10 import { usePageTitle } from "../hooks/usePageTitle"; ··· 22 22 Megaphone, 23 23 ChevronDown, 24 24 } from "lucide-react"; 25 - import type { News } from "../lib/bbs"; 25 + import type { NewsPost } from "../lib/bbs"; 26 26 import type { BBSLoaderData } from "../router/loaders"; 27 27 import PostBody from "../components/post/PostBody"; 28 28 ··· 32 32 const [newsTitle, setNewsTitle] = useState(""); 33 33 const [newsBody, setNewsBody] = useState(""); 34 34 const [newsFiles, setNewsFiles] = useState<File[]>([]); 35 - const [pendingNews, setPendingNews] = useState<News[]>([]); 35 + const [pendingNews, setPendingNews] = useState<NewsPost[]>([]); 36 36 const [deletedTids, setDeletedTids] = useState<Set<string>>(new Set()); 37 37 const [showAllNews, setShowAllNews] = useState(false); 38 38 ··· 42 42 ); 43 43 usePageTitle(`${bbs.site.name} — atbbs`); 44 44 45 - if (user && bbs.site.bannedDids.has(user.did)) 46 - return ( 47 - <p className="text-neutral-400">You have been banned from this BBS.</p> 48 - ); 49 - 50 45 const isSysop = user && user.did === bbs.identity.did; 51 46 52 47 async function postNews(e: SyntheticEvent) { ··· 56 51 const body = newsBody.trim(); 57 52 const siteUri = makeAtUri(bbs.identity.did, SITE, "self"); 58 53 const attachments = await uploadAttachments(agent, newsFiles); 59 - const resp = await createNews(agent, siteUri, title, body, attachments); 60 - const tid = parseAtUri(resp.data.uri).rkey; 54 + const resp = await createPost(agent, siteUri, body, { 55 + title, 56 + attachments, 57 + }); 58 + const rkey = parseAtUri(resp.data.uri).rkey; 61 59 setPendingNews((prev) => [ 62 - { tid, siteUri, title, body, createdAt: nowIso() }, 60 + { uri: resp.data.uri, rkey, title, body, createdAt: nowIso() }, 63 61 ...prev, 64 62 ]); 65 63 setNewsTitle(""); ··· 67 65 setNewsFiles([]); 68 66 } 69 67 70 - async function removeNews(tid: string) { 68 + async function removeNews(rkey: string) { 71 69 if (!agent) return; 72 70 if (!confirm("Delete this news post?")) return; 73 - await deleteRecord(agent, NEWS, tid); 74 - setPendingNews((prev) => prev.filter((n) => n.tid !== tid)); 75 - setDeletedTids((prev) => new Set(prev).add(tid)); 71 + await deleteRecord(agent, POST, rkey); 72 + setPendingNews((prev) => prev.filter((n) => n.rkey !== rkey)); 73 + setDeletedTids((prev) => new Set(prev).add(rkey)); 76 74 } 77 75 78 - // Merge pending news with loader data, deduplicating by tid and filtering deletes. 79 - const loaderTids = new Set(bbs.news.map((n) => n.tid)); 76 + // Merge pending news with loader data, deduplicating by rkey and filtering deletes. 77 + const loaderTids = new Set(bbs.news.map((n) => n.rkey)); 80 78 const allNews = [ 81 79 ...pendingNews.filter( 82 - (n) => !loaderTids.has(n.tid) && !deletedTids.has(n.tid), 80 + (n) => !loaderTids.has(n.rkey) && !deletedTids.has(n.rkey), 83 81 ), 84 - ...bbs.news.filter((n) => !deletedTids.has(n.tid)), 82 + ...bbs.news.filter((n) => !deletedTids.has(n.rkey)), 85 83 ]; 86 84 const visibleNews = showAllNews ? allNews : allNews.slice(0, 3); 87 85 ··· 146 144 title={newsTitle} 147 145 onTitleChange={setNewsTitle} 148 146 titlePlaceholder="Headline" 149 - titleMaxLength={limits.NEWS_TITLE} 147 + titleMaxLength={limits.POST_TITLE} 150 148 body={newsBody} 151 149 onBodyChange={setNewsBody} 152 150 bodyPlaceholder="Announcement body..." 153 151 bodyRows={3} 154 - bodyMaxLength={limits.NEWS_BODY} 152 + bodyMaxLength={limits.POST_BODY} 155 153 files={newsFiles} 156 154 onFilesChange={setNewsFiles} 157 155 submitLabel="post" ··· 163 161 <> 164 162 {visibleNews.map((item, i) => ( 165 163 <Link 166 - key={item.tid} 167 - to={`/bbs/${handle}/news/${item.tid}`} 164 + key={item.rkey} 165 + to={`/bbs/${handle}/news/${item.rkey}`} 168 166 className={`reply-card block bg-neutral-900 border border-neutral-800 rounded p-4 hover:border-neutral-700 ${i < visibleNews.length - 1 ? "mb-2" : ""}`} 169 167 > 170 168 <div className="flex items-baseline justify-between mb-2"> ··· 179 177 type="button" 180 178 onClick={(e) => { 181 179 e.preventDefault(); 182 - removeNews(item.tid); 180 + removeNews(item.rkey); 183 181 }} 184 182 className="text-xs text-neutral-400 hover:text-red-400" 185 183 >
+8 -13
web/src/pages/Board.tsx
··· 11 11 import { usePageTitle } from "../hooks/usePageTitle"; 12 12 import { makeAtUri, parseAtUri, relativeDate } from "../lib/util"; 13 13 import { BOARD } from "../lib/lexicon"; 14 - import { createThread, uploadAttachments } from "../lib/writes"; 14 + import { createPost, uploadAttachments } from "../lib/writes"; 15 15 import * as limits from "../lib/limits"; 16 16 import ThreadLink from "../components/nav/ThreadLink"; 17 17 import ComposeForm from "../components/form/ComposeForm"; ··· 80 80 return; 81 81 } 82 82 try { 83 - const { makeAtUri } = await import("../lib/util"); 84 - const { BOARD: BOARD_COL } = await import("../lib/lexicon"); 85 - const boardUri = makeAtUri(bbs.identity.did, BOARD_COL, board.slug); 83 + const boardUri = makeAtUri(bbs.identity.did, BOARD, board.slug); 86 84 const attachments = await uploadAttachments(agent, files); 87 - const resp = await createThread( 88 - agent, 89 - boardUri, 90 - title.trim(), 91 - body.trim(), 85 + const resp = await createPost(agent, boardUri, body.trim(), { 86 + title: title.trim(), 92 87 attachments, 93 - ); 88 + }); 94 89 setTitle(""); 95 90 setBody(""); 96 91 setFiles([]); ··· 98 93 const { did, rkey } = parseAtUri(resp.data.uri); 99 94 navigate(`/bbs/${handle}/thread/${did}/${rkey}`); 100 95 } catch (err: unknown) { 101 - console.error("createThread failed:", err); 96 + console.error("createPost failed:", err); 102 97 alert(`Could not post: ${err instanceof Error ? err.message : err}`); 103 98 } 104 99 } ··· 121 116 title={title} 122 117 onTitleChange={setTitle} 123 118 titlePlaceholder="Thread title" 124 - titleMaxLength={limits.THREAD_TITLE} 119 + titleMaxLength={limits.POST_TITLE} 125 120 body={body} 126 121 onBodyChange={setBody} 127 - bodyMaxLength={limits.THREAD_BODY} 122 + bodyMaxLength={limits.POST_BODY} 128 123 files={files} 129 124 onFilesChange={setFiles} 130 125 />
+1 -1
web/src/pages/Dashboard.tsx
··· 111 111 {tab === "inbox" && ( 112 112 <> 113 113 <p className="text-neutral-400 text-xs mb-4"> 114 - Recent replies and quotes from other users. 114 + Recent replies from other users. 115 115 </p> 116 116 <Suspense fallback={loading}> 117 117 <Await resolve={activity}>
+3 -3
web/src/pages/News.tsx
··· 2 2 import { useAuth } from "../lib/auth"; 3 3 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 4 4 import { usePageTitle } from "../hooks/usePageTitle"; 5 - import { NEWS } from "../lib/lexicon"; 5 + import { POST } from "../lib/lexicon"; 6 6 import { deleteRecord } from "../lib/writes"; 7 7 import type { BBSLoaderData } from "../router/loaders"; 8 8 import NewsCard from "../components/post/NewsCard"; ··· 13 13 const { user, agent } = useAuth(); 14 14 const navigate = useNavigate(); 15 15 16 - const item = bbs.news.find((news) => news.tid === tid); 16 + const item = bbs.news.find((news) => news.rkey === tid); 17 17 18 18 useBreadcrumb( 19 19 [ ··· 35 35 async function onDelete() { 36 36 if (!agent || !tid) return; 37 37 if (!confirm("Delete this news post?")) return; 38 - await deleteRecord(agent, NEWS, tid); 38 + await deleteRecord(agent, POST, tid); 39 39 navigate(`/bbs/${handle}`); 40 40 } 41 41
+3 -2
web/src/pages/SysopCreate.tsx
··· 2 2 import { useNavigate, useLoaderData } from "react-router-dom"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { putBoard, putSite } from "../lib/writes"; 5 - import { nowIso } from "../lib/util"; 5 + import { BOARD } from "../lib/lexicon"; 6 + import { makeAtUri, nowIso } from "../lib/util"; 6 7 import * as limits from "../lib/limits"; 7 8 import { usePageTitle } from "../hooks/usePageTitle"; 8 9 import { Input, Textarea, Button } from "../components/form/Form"; ··· 59 60 name: name.trim(), 60 61 description: description.trim(), 61 62 intro, 62 - boards: cleanBoards.map((board) => board.slug), 63 + boards: cleanBoards.map((board) => makeAtUri(user.did, BOARD, board.slug)), 63 64 createdAt: now, 64 65 }); 65 66 navigate(`/bbs/${user.handle}`);
+3 -2
web/src/pages/SysopEdit.tsx
··· 2 2 import { useLoaderData, useNavigate } from "react-router-dom"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { putBoard, putSite } from "../lib/writes"; 5 - import { nowIso } from "../lib/util"; 5 + import { BOARD } from "../lib/lexicon"; 6 + import { makeAtUri, nowIso } from "../lib/util"; 6 7 import * as limits from "../lib/limits"; 7 8 import { usePageTitle } from "../hooks/usePageTitle"; 8 9 import { Input, Textarea, Button } from "../components/form/Form"; ··· 61 62 name: name.trim(), 62 63 description: description.trim(), 63 64 intro, 64 - boards: cleanBoards.map((board) => board.slug), 65 + boards: cleanBoards.map((board) => makeAtUri(user.did, BOARD, board.slug)), 65 66 createdAt: bbs.site.createdAt || now, 66 67 updatedAt: now, 67 68 });
+1 -1
web/src/pages/SysopModerate.tsx
··· 86 86 <div> 87 87 <label className="block text-neutral-400 mb-3">Banned Users</label> 88 88 <div className="space-y-1 mb-3"> 89 - {[...bbs.site.bannedDids].map((did) => ( 89 + {Object.keys(banRkeys).map((did) => ( 90 90 <div 91 91 key={did} 92 92 title={did}
+30 -23
web/src/pages/Thread.tsx
··· 9 9 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 10 10 import { usePageTitle } from "../hooks/usePageTitle"; 11 11 import { useThreadReplies } from "../hooks/useThreadReplies"; 12 - import { THREAD, REPLY } from "../lib/lexicon"; 12 + import { POST } from "../lib/lexicon"; 13 13 import { makeAtUri, parseAtUri } from "../lib/util"; 14 14 import * as limits from "../lib/limits"; 15 15 import { 16 16 createBan, 17 17 createHide, 18 - createReply, 18 + createPost, 19 19 deleteRecord, 20 20 uploadAttachments, 21 21 } from "../lib/writes"; ··· 65 65 66 66 const [body, setBody] = useState(""); 67 67 const [files, setFiles] = useState<File[]>([]); 68 - const [quote, setQuote] = useState<{ uri: string; handle: string } | null>( 69 - null, 70 - ); 68 + const [replyingTo, setReplyingTo] = useState<{ 69 + uri: string; 70 + handle: string; 71 + } | null>(null); 71 72 const [posting, setPosting] = useState(false); 72 73 73 74 usePageTitle(`${thread.title} — ${bbs.site.name}`); ··· 80 81 if (!agent || !user) return; 81 82 setPosting(true); 82 83 try { 83 - const threadUri = makeAtUri(thread.did, THREAD, thread.rkey); 84 + const threadUri = makeAtUri(thread.did, POST, thread.rkey); 84 85 const attachments = await uploadAttachments(agent, files); 85 - const resp = await createReply( 86 - agent, 87 - threadUri, 88 - body.trim(), 89 - quote?.uri ?? null, 86 + const { BOARD } = await import("../lib/lexicon"); 87 + const boardUri = makeAtUri(bbs.identity.did, BOARD, thread.boardSlug); 88 + const resp = await createPost(agent, boardUri, body.trim(), { 89 + root: threadUri, 90 + parent: replyingTo?.uri ?? undefined, 90 91 attachments, 91 - ); 92 + }); 92 93 addOptimisticReply({ 93 94 uri: resp.data.uri, 94 95 did: parseAtUri(resp.data.uri).did, ··· 97 98 pds: user.pdsUrl, 98 99 body: body.trim(), 99 100 createdAt: new Date().toISOString(), 100 - quote: quote?.uri ?? null, 101 + parent: replyingTo?.uri ?? null, 101 102 attachments: attachments as Reply["attachments"], 102 103 }); 103 104 setBody(""); 104 105 setFiles([]); 105 - setQuote(null); 106 + setReplyingTo(null); 106 107 } catch { 107 108 alert("Could not post reply."); 108 109 } finally { ··· 113 114 async function onDeleteThread() { 114 115 if (!agent) return; 115 116 if (!confirm("Delete this thread?")) return; 116 - await deleteRecord(agent, THREAD, thread.rkey); 117 + await deleteRecord(agent, POST, thread.rkey); 117 118 navigate(`/bbs/${handle}`); 118 119 } 119 120 ··· 121 122 if (!agent) return; 122 123 if (!confirm("Delete this reply?")) return; 123 124 try { 124 - await deleteRecord(agent, REPLY, reply.rkey); 125 + await deleteRecord(agent, POST, reply.rkey); 125 126 } catch (e: unknown) { 126 127 console.error("deleteRecord failed:", e); 127 128 alert(`Could not delete: ${e instanceof Error ? e.message : e}`); ··· 172 173 reply={reply} 173 174 userDid={user?.did ?? ""} 174 175 sysopDid={bbs.identity.did} 175 - quoted={reply.quote ? replyCache[reply.quote] : undefined} 176 - onQuote={() => setQuote({ uri: reply.uri, handle: reply.handle })} 177 - onQuoteClick={ 178 - reply.quote ? () => scrollToReply(reply.quote!) : undefined 176 + parentPost={ 177 + reply.parent ? replyCache[reply.parent] : undefined 178 + } 179 + onReplyTo={() => 180 + setReplyingTo({ uri: reply.uri, handle: reply.handle }) 181 + } 182 + onParentClick={ 183 + reply.parent 184 + ? () => scrollToReply(reply.parent!) 185 + : undefined 179 186 } 180 187 onDelete={() => onDeleteReply(reply)} 181 188 onBan={() => onBan(reply.did)} ··· 199 206 onBodyChange={setBody} 200 207 bodyPlaceholder="Write a reply..." 201 208 bodyRows={3} 202 - bodyMaxLength={limits.REPLY_BODY} 209 + bodyMaxLength={limits.POST_BODY} 203 210 files={files} 204 211 onFilesChange={setFiles} 205 - quote={quote} 206 - onClearQuote={() => setQuote(null)} 212 + replyingTo={replyingTo} 213 + onClearReplyTo={() => setReplyingTo(null)} 207 214 submitLabel="reply" 208 215 posting={posting} 209 216 />
+9 -12
web/src/router/loaders/board.ts
··· 6 6 resolveIdentitiesBatch, 7 7 type ATRecord, 8 8 } from "../../lib/atproto"; 9 - import { THREAD, BOARD } from "../../lib/lexicon"; 9 + import { POST, BOARD } from "../../lib/lexicon"; 10 10 import { makeAtUri, parseAtUri } from "../../lib/util"; 11 11 import { is } from "@atcute/lexicons/validations"; 12 - import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread"; 13 - import type { XyzAtboardsThread } from "../../lexicons"; 12 + import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; 13 + import type { XyzAtbbsPost } from "../../lexicons"; 14 14 15 15 export interface ThreadItem { 16 16 uri: string; ··· 28 28 cursor?: string, 29 29 ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { 30 30 const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); 31 - const backlinks = await getBacklinks(boardUri, `${THREAD}:board`, 50, cursor); 31 + const backlinks = await getBacklinks(boardUri, `${POST}:scope`, 50, cursor); 32 32 const records = await getRecordsBatch(backlinks.records); 33 33 const filtered = records.filter((record) => { 34 - const { did } = parseAtUri(record.uri); 35 - return ( 36 - !bbs.site.bannedDids.has(did) && 37 - !bbs.site.hiddenPosts.has(record.uri) && 38 - is(threadSchema, record.value) 39 - ); 34 + if (!is(postSchema, record.value)) return false; 35 + const value = record.value as unknown as XyzAtbbsPost.Main; 36 + return value.title && !value.root; // root posts with titles = threads 40 37 }); 41 38 const authors = await resolveIdentitiesBatch( 42 39 filtered.map((record) => parseAtUri(record.uri).did), ··· 45 42 .filter((record) => parseAtUri(record.uri).did in authors) 46 43 .map((record: ATRecord) => { 47 44 const { did, rkey } = parseAtUri(record.uri); 48 - const value = record.value as unknown as XyzAtboardsThread.Main; 45 + const value = record.value as unknown as XyzAtbbsPost.Main; 49 46 return { 50 47 uri: record.uri, 51 48 did, 52 49 rkey, 53 50 handle: authors[did].handle, 54 - title: value.title, 51 + title: value.title ?? "", 55 52 body: value.body, 56 53 createdAt: value.createdAt, 57 54 };
+17 -16
web/src/router/loaders/sysop.ts
··· 8 8 import { BAN, HIDE } from "../../lib/lexicon"; 9 9 import { parseAtUri } from "../../lib/util"; 10 10 import { is } from "@atcute/lexicons/validations"; 11 - import { mainSchema as banSchema } from "../../lexicons/types/xyz/atboards/ban"; 12 - import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atboards/hide"; 13 - import type { XyzAtboardsBan, XyzAtboardsHide } from "../../lexicons"; 11 + import { mainSchema as banSchema } from "../../lexicons/types/xyz/atbbs/ban"; 12 + import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atbbs/hide"; 13 + import type { XyzAtbbsBan, XyzAtbbsHide } from "../../lexicons"; 14 14 import { requireAuth } from "./auth"; 15 15 16 16 export interface HiddenInfo { ··· 33 33 return map; 34 34 } 35 35 36 - async function hydrateHiddenPosts(uris: Set<string>): Promise<HiddenInfo[]> { 37 - if (uris.size === 0) return []; 36 + async function hydrateHiddenPosts(uris: string[]): Promise<HiddenInfo[]> { 37 + if (uris.length === 0) return []; 38 38 39 - const uriList = [...uris]; 40 - const dids = [...new Set(uriList.map((uri) => parseAtUri(uri).did))]; 39 + const dids = [...new Set(uris.map((uri) => parseAtUri(uri).did))]; 41 40 42 41 const [identities, records] = await Promise.all([ 43 42 resolveIdentitiesBatch(dids), 44 - Promise.allSettled(uriList.map(getRecordByUri)), 43 + Promise.allSettled(uris.map(getRecordByUri)), 45 44 ]); 46 45 47 - return uriList.map((uri, index) => { 46 + return uris.map((uri, index) => { 48 47 const did = parseAtUri(uri).did; 49 48 const handle = identities[did]?.handle ?? did; 50 49 const result = records[index]; ··· 91 90 listRecords(user.pdsUrl, user.did, HIDE), 92 91 ]); 93 92 94 - const banRkeys = buildRkeyMap<XyzAtboardsBan.Main>( 93 + const banRkeys = buildRkeyMap<XyzAtbbsBan.Main>( 95 94 banRecs, 96 95 banSchema, 97 96 (ban) => ban.did, 98 97 ); 99 - const hideRkeys = buildRkeyMap<XyzAtboardsHide.Main>( 98 + const hideRkeys = buildRkeyMap<XyzAtbbsHide.Main>( 100 99 hideRecs, 101 100 hideSchema, 102 101 (hide) => hide.uri, 103 102 ); 104 103 104 + const bannedDids = Object.keys(banRkeys); 105 105 let bannedHandles: Record<string, string> = {}; 106 - if (bbs.site.bannedDids.size) { 106 + if (bannedDids.length) { 107 107 try { 108 - const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]); 109 - for (const did of bbs.site.bannedDids) 108 + const authors = await resolveIdentitiesBatch(bannedDids); 109 + for (const did of bannedDids) 110 110 bannedHandles[did] = authors[did]?.handle ?? did; 111 111 } catch { 112 - for (const did of bbs.site.bannedDids) bannedHandles[did] = did; 112 + for (const did of bannedDids) bannedHandles[did] = did; 113 113 } 114 114 } 115 115 116 - const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts); 116 + const hiddenUris = Object.keys(hideRkeys); 117 + const hidden = await hydrateHiddenPosts(hiddenUris); 117 118 118 119 return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden }; 119 120 }
+15 -15
web/src/router/loaders/thread.ts
··· 6 6 resolveIdentity, 7 7 type BacklinkRef, 8 8 } from "../../lib/atproto"; 9 - import { THREAD, REPLY } from "../../lib/lexicon"; 9 + import { POST } from "../../lib/lexicon"; 10 10 import { makeAtUri, parseAtUri } from "../../lib/util"; 11 11 import { is } from "@atcute/lexicons/validations"; 12 - import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread"; 13 - import type { XyzAtboardsThread } from "../../lexicons"; 12 + import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; 13 + import type { XyzAtbbsPost } from "../../lexicons"; 14 14 15 15 export interface ThreadObj { 16 16 uri: string; ··· 25 25 attachments?: { file: { ref: { $link: string } }; name: string }[]; 26 26 } 27 27 28 - async function collectAllReplyRefs(threadUri: string): Promise<BacklinkRef[]> { 28 + async function collectAllReplyRefs(rootUri: string): Promise<BacklinkRef[]> { 29 29 const collected: BacklinkRef[] = []; 30 30 let cursor: string | undefined; 31 31 for (let i = 0; i < 20; i++) { 32 - const page = await getBacklinks(threadUri, `${REPLY}:subject`, 100, cursor); 32 + const page = await getBacklinks(rootUri, `${POST}:root`, 100, cursor); 33 33 collected.push(...page.records); 34 34 if (!page.cursor) break; 35 35 cursor = page.cursor; ··· 42 42 const did = params.did!; 43 43 const tid = params.tid!; 44 44 45 - const threadUri = makeAtUri(did, THREAD, tid); 45 + const threadUri = makeAtUri(did, POST, tid); 46 46 const [bbs, threadRecord, author, allRefs] = await Promise.all([ 47 47 resolveBBS(handle), 48 - getRecord(did, THREAD, tid), 48 + getRecord(did, POST, tid), 49 49 resolveIdentity(did), 50 50 collectAllReplyRefs(threadUri), 51 51 ]); 52 - if (!is(threadSchema, threadRecord.value)) { 53 - throw new Response("Invalid thread record", { status: 404 }); 52 + if (!is(postSchema, threadRecord.value)) { 53 + throw new Response("Invalid post record", { status: 404 }); 54 54 } 55 - const threadValue = threadRecord.value as unknown as XyzAtboardsThread.Main; 56 - const boardSlug = parseAtUri(threadValue.board).rkey; 55 + const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; 56 + const boardSlug = parseAtUri(postValue.scope).rkey; 57 57 const thread: ThreadObj = { 58 58 uri: threadRecord.uri, 59 59 did, 60 60 rkey: tid, 61 61 authorHandle: author.handle, 62 62 authorPds: author.pds ?? "", 63 - title: threadValue.title, 64 - body: threadValue.body, 65 - createdAt: threadValue.createdAt, 63 + title: postValue.title ?? "", 64 + body: postValue.body, 65 + createdAt: postValue.createdAt, 66 66 boardSlug, 67 - attachments: threadValue.attachments as ThreadObj["attachments"], 67 + attachments: postValue.attachments as ThreadObj["attachments"], 68 68 }; 69 69 70 70 return { handle, bbs, thread, allRefs };
+1 -1
web/tsconfig.tsbuildinfo
··· 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"} 1 + {"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"}
+7 -9
web/vite.config.ts
··· 8 8 const SCOPE = [ 9 9 "atproto", 10 10 "blob:*/*", 11 - "repo:xyz.atboards.site", 12 - "repo:xyz.atboards.board", 13 - "repo:xyz.atboards.news", 14 - "repo:xyz.atboards.thread", 15 - "repo:xyz.atboards.reply", 16 - "repo:xyz.atboards.ban", 17 - "repo:xyz.atboards.hide", 18 - "repo:xyz.atboards.pin", 19 - "repo:xyz.atboards.profile", 11 + "repo:xyz.atbbs.site", 12 + "repo:xyz.atbbs.board", 13 + "repo:xyz.atbbs.post", 14 + "repo:xyz.atbbs.ban", 15 + "repo:xyz.atbbs.hide", 16 + "repo:xyz.atbbs.pin", 17 + "repo:xyz.atbbs.profile", 20 18 ].join(" "); 21 19 22 20 interface ClientMetadata {