···88import httpx
991010from core import lexicon
1111-from core.constellation import get_replies, get_root_posts
1111+from core.constellation import get_board_activity, get_replies, get_root_posts
1212from core.filters import filter_moderated
1313from core.models import AtUri, AuthError, BBS, Board, MiniDoc, Post, Record
1414-from core.slingshot import get_records_batch, resolve_identities_batch
1414+from core.slingshot import get_records_batch, get_records_by_uri, resolve_identities_batch
1515from core.util import now_iso
16161717···3838 banned_dids: set[str] | None = None,
3939 hidden_posts: set[str] | None = None,
4040 cursor: str | None = None,
4141+ page_size: int = 25,
4142) -> tuple[list[Post], str | None]:
4242- """Fetch and hydrate root posts (threads) for a board."""
4343+ """Fetch threads for a board, sorted by last activity (bump order).
4444+4545+ Scans recent board activity (threads + replies) and collects unique
4646+ thread URIs in the order they appear. Since Constellation returns
4747+ newest posts first, the first time a thread URI appears is its most
4848+ recent activity — giving us bump order naturally.
4949+ """
4350 board_uri = str(AtUri(bbs.identity.did, lexicon.BOARD, board.slug))
4444- backlinks = await get_root_posts(client, board_uri, cursor=cursor)
4545- records = await get_records_batch(client, backlinks.records)
5151+ max_scans = 4
5252+5353+ # Phase 1: Scan board activity to find unique thread URIs
5454+ # Keys are thread URIs, values are the timestamp of their last activity.
5555+ last_activity: dict[str, str] = {}
5656+ scan_cursor = cursor
5757+5858+ for scan in range(max_scans):
5959+ if len(last_activity) >= page_size:
6060+ break
6161+6262+ backlinks = await get_board_activity(client, board_uri, cursor=scan_cursor)
6363+ if not backlinks.records:
6464+ break
6565+6666+ records = await get_records_batch(client, backlinks.records)
6767+ if banned_dids or hidden_posts:
6868+ records = filter_moderated(records, banned_dids or set(), hidden_posts or set())
6969+7070+ for record in records:
7171+ thread_uri = record.value.get("root") or record.uri
7272+ if thread_uri not in last_activity:
7373+ last_activity[thread_uri] = record.value.get("createdAt", "")
7474+7575+ scan_cursor = backlinks.cursor
7676+ if not scan_cursor:
7777+ break
7878+7979+ # Phase 2: Fetch root post records for the thread URIs
8080+ thread_uris = list(last_activity.keys())[:page_size]
8181+ root_records = await get_records_by_uri(client, thread_uris)
8282+ root_records = [record for record in root_records if not record.value.get("root")]
4683 if banned_dids or hidden_posts:
4747- records = filter_moderated(records, banned_dids or set(), hidden_posts or set())
8484+ root_records = filter_moderated(root_records, banned_dids or set(), hidden_posts or set())
48854949- parsed = {record.uri: AtUri.parse(record.uri) for record in records}
5050- dids = [parsed_uri.did for parsed_uri in parsed.values()]
5151- authors = await resolve_identities_batch(client, dids)
8686+ # Phase 3: Resolve authors and build Post objects
8787+ uri_to_did = {record.uri: AtUri.parse(record.uri).did for record in root_records}
8888+ authors = await resolve_identities_batch(client, list(uri_to_did.values()))
52895390 threads = [
5454- post_from_record(record, authors[parsed[record.uri].did])
5555- for record in records
5656- if parsed[record.uri].did in authors
9191+ post_from_record(record, authors[uri_to_did[record.uri]])
9292+ for record in root_records
9393+ if uri_to_did[record.uri] in authors
5794 ]
5858- # Filter to root posts only (no root field = thread, not a reply)
5959- threads = [thread for thread in threads if thread.is_root]
6060- threads.sort(key=lambda thread: thread.created_at, reverse=True)
6161- return threads, backlinks.cursor
9595+9696+ # Set last_activity_at and sort by it (bump order)
9797+ for thread in threads:
9898+ thread.last_activity_at = last_activity.get(thread.uri, thread.created_at)
9999+ threads.sort(key=lambda thread: thread.last_activity_at or thread.created_at, reverse=True)
100100+101101+ return threads, scan_cursor
621026310364104@dataclass
+11-1
core/slingshot.py
···33import httpx
4455from core.cache import TTLCache
66-from core.models import BacklinkRef, MiniDoc, Record
66+from core.models import AtUri, BacklinkRef, MiniDoc, Record
7788BASE_URL = "https://slingshot.microcosm.blue/xrpc"
99···6464 tasks = [resolve_identity(client, did) for did in dids]
6565 results = await asyncio.gather(*tasks, return_exceptions=True)
6666 return {r.did: r for r in results if isinstance(r, MiniDoc)}
6767+6868+6969+async def get_records_by_uri(
7070+ client: httpx.AsyncClient, uris: list[str]
7171+) -> list[Record]:
7272+ """Fetch multiple records by AT-URI, skipping failures."""
7373+ parsed = [AtUri.parse(uri) for uri in uris]
7474+ tasks = [get_record(client, at_uri.did, at_uri.collection, at_uri.rkey) for at_uri in parsed]
7575+ results = await asyncio.gather(*tasks, return_exceptions=True)
7676+ return [result for result in results if isinstance(result, Record)]
677768786979async def get_records_batch(
+1-1
telnet/server.py
···124124 await write(writer, " No threads yet.\r\n")
125125 else:
126126 for index, thread in enumerate(threads, 1):
127127- date = format_datetime_utc(thread.created_at)
127127+ date = format_datetime_utc(thread.last_activity_at or thread.created_at)
128128 await write(
129129 writer, f" {index}. {thread.title} · {thread.author.handle} · {date}\r\n"
130130 )
+1-1
tui/screens/board.py
···7070 thread_list = self.query_one("#thread-list", ListView)
7171 thread_list.clear()
7272 for thread in self.threads:
7373- label = f" {thread.title} — {thread.author.handle} · {format_datetime(thread.created_at)}"
7373+ label = f" {thread.title} — {thread.author.handle} · {format_datetime(thread.last_activity_at or thread.created_at)}"
7474 await thread_list.append(ListItem(Static(label), name=thread.uri))
75757676 if self.threads: