···66import { Button } from "../form/Form";
77import { useDropdown } from "../../hooks/useDropdown";
88import { useResolvedBBS } from "../../hooks/useResolvedBBS";
99-import type { DiscoveredBBS } from "../../hooks/useDiscovery";
99+import type { DiscoveredBBS } from "../../lib/discovery";
10101111export interface Suggestion {
1212 to: string;
+1-1
web/src/components/dashboard/DiscoveryList.tsx
···11import ListLink from "../nav/ListLink";
22-import type { DiscoveredBBS } from "../../hooks/useDiscovery";
22+import type { DiscoveredBBS } from "../../lib/discovery";
3344interface DiscoveryListProps {
55 discovered: DiscoveredBBS[];
+1-1
web/src/components/dashboard/MyThreadList.tsx
···22import { ChevronDown } from "lucide-react";
33import { Link } from "react-router-dom";
44import { parseAtUri, formatFullDate, relativeDate } from "../../lib/util";
55-import type { MyThread } from "../../router/loaders";
55+import type { MyThread } from "../../lib/mythreads";
6677const PAGE_SIZE = 10;
88
+1-1
web/src/components/dashboard/PinnedList.tsx
···11import { useState } from "react";
22import { ChevronDown } from "lucide-react";
33import ListLink from "../nav/ListLink";
44-import type { PinnedBBS } from "../../router/loaders";
44+import type { PinnedBBS } from "../../lib/pins";
5566const PAGE_SIZE = 5;
77
···11import { Link } from "react-router-dom";
22import Avatar from "../Avatar";
33-import type { Participant } from "../../router/loaders/board";
33+import type { Participant } from "../../lib/boardThreads";
4455const COL_POSTERS = "w-20";
66const COL_REPLIES = "w-14";
···11/** Resolve a handle to a fully hydrated BBS via Slingshot/Constellation. */
2233-import { TTLCache } from "./cache";
43import {
54 getRecord,
66- getRecordsBatch,
77- getBacklinks,
85 resolveIdentity,
96 type MiniDoc,
107 type ATRecord,
118} from "./atproto";
1212-import { SITE, BOARD, POST, BAN, HIDE } from "./lexicon";
1313-import { makeAtUri, parseAtUri } from "./util";
99+import { queryClient } from "./queryClient";
1010+import { SITE } from "./lexicon";
1111+import { parseAtUri } from "./util";
1412import { is } from "@atcute/lexicons/validations";
1513import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
1614import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board";
1717-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
1818-import type { XyzAtbbsSite, XyzAtbbsBoard, XyzAtbbsPost } from "../lexicons";
1515+import type { XyzAtbbsSite, XyzAtbbsBoard } from "../lexicons";
19162017export class BBSNotFoundError extends Error {}
2118export class NoBBSError extends Error {}
2222-export class NetworkError extends Error {}
23192420export interface Board {
2521 slug: string;
···5551export interface BBS {
5652 identity: MiniDoc;
5753 site: Site;
5858- news: NewsPost[];
5954}
60556161-const bbsCache = new TTLCache<string, BBS>(5 * 60 * 1000);
6262-6363-export function invalidateBBSCache() {
6464- bbsCache.clear();
5656+export function invalidateAllBBSCaches() {
5757+ queryClient.invalidateQueries({ queryKey: ["bbs"] });
5858+ queryClient.invalidateQueries({ queryKey: ["bbs-moderation"] });
5959+ queryClient.invalidateQueries({ queryKey: ["sysop-moderation"] });
6560}
66616762export async function resolveBBS(handle: string): Promise<BBS> {
6868- const cached = bbsCache.get(handle);
6969- if (cached) return cached;
7070- const bbs = await _resolveBBS(handle);
7171- bbsCache.set(handle, bbs);
7272- return bbs;
7373-}
7474-7575-async function _resolveBBS(handle: string): Promise<BBS> {
7663 let identity: MiniDoc;
7764 try {
7865 identity = await resolveIdentity(handle);
7979- } catch (e) {
6666+ } catch {
8067 throw new BBSNotFoundError(`Could not resolve handle: ${handle}`);
8168 }
8269 if (!identity.pds) {
···9481 throw new NoBBSError(`${handle} has an invalid site record.`);
9582 }
9683 const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main;
9797- const siteUri = makeAtUri(identity.did, SITE, "self");
9884 const boardUris: string[] = siteValue.boards ?? [];
9985100100- const [boardResults, newsBacklinks] = await Promise.all([
101101- Promise.allSettled(
102102- boardUris.map((uri) => {
103103- const parsed = parseAtUri(uri);
104104- return getRecord(parsed.did, parsed.collection, parsed.rkey);
105105- }),
106106- ),
107107- getBacklinks(siteUri, `${POST}:scope`, 50).catch(() => null),
108108- ]);
8686+ const boardResults = await Promise.allSettled(
8787+ boardUris.map((uri) => {
8888+ const parsed = parseAtUri(uri);
8989+ return getRecord(parsed.did, parsed.collection, parsed.rkey);
9090+ }),
9191+ );
1099211093 const boards: Board[] = [];
11194 boardResults.forEach((result, index) => {
···122105 });
123106 });
124107125125- // News - posts scoped to the site, only sysop's repo
126126- let news: NewsPost[] = [];
127127- if (newsBacklinks) {
128128- const sysopRefs = newsBacklinks.records.filter(
129129- (ref) => ref.did === identity.did,
130130- );
131131- const newsRecords = await getRecordsBatch(sysopRefs);
132132- news = newsRecords
133133- .filter((record) => is(postSchema, record.value))
134134- .filter((record) => {
135135- const value = record.value as unknown as XyzAtbbsPost.Main;
136136- return value.title && !value.root; // root posts with titles are news/threads
137137- })
138138- .map((record) => {
139139- const value = record.value as unknown as XyzAtbbsPost.Main;
140140- return {
141141- uri: record.uri,
142142- rkey: parseAtUri(record.uri).rkey,
143143- title: value.title ?? "",
144144- body: value.body,
145145- createdAt: value.createdAt,
146146- attachments: value.attachments as PostAttachment[] | undefined,
147147- };
148148- });
149149- news.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
150150- }
151151-152108 return {
153109 identity,
154110 site: {
···159115 createdAt: siteValue.createdAt ?? "",
160116 updatedAt: siteValue.updatedAt,
161117 },
162162- news,
163118 };
164119}
+49
web/src/lib/bbsModeration.ts
···11+/** Lookup tables for a BBS's moderation state: who is banned, which posts
22+ * are hidden, and the rkeys of those records (so the sysop can undo). */
33+44+import { listRecords } from "./atproto";
55+import { BAN, HIDE } from "./lexicon";
66+import { parseAtUri } from "./util";
77+import { is } from "@atcute/lexicons/validations";
88+import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban";
99+import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide";
1010+import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons";
1111+1212+export interface BBSModeration {
1313+ bannedDids: Set<string>;
1414+ hiddenUris: Set<string>;
1515+ /** DID → rkey of that user's ban record on the sysop's PDS. */
1616+ banRkeys: Record<string, string>;
1717+ /** Post URI → rkey of its hide record on the sysop's PDS. */
1818+ hideRkeys: Record<string, string>;
1919+}
2020+2121+export async function fetchBBSModeration(
2222+ pdsUrl: string,
2323+ did: string,
2424+): Promise<BBSModeration> {
2525+ const [banRecs, hideRecs] = await Promise.all([
2626+ listRecords(pdsUrl, did, BAN).catch(() => []),
2727+ listRecords(pdsUrl, did, HIDE).catch(() => []),
2828+ ]);
2929+3030+ const bannedDids = new Set<string>();
3131+ const banRkeys: Record<string, string> = {};
3232+ for (const record of banRecs) {
3333+ if (!is(banSchema, record.value)) continue;
3434+ const value = record.value as unknown as XyzAtbbsBan.Main;
3535+ bannedDids.add(value.did);
3636+ banRkeys[value.did] = parseAtUri(record.uri).rkey;
3737+ }
3838+3939+ const hiddenUris = new Set<string>();
4040+ const hideRkeys: Record<string, string> = {};
4141+ for (const record of hideRecs) {
4242+ if (!is(hideSchema, record.value)) continue;
4343+ const value = record.value as unknown as XyzAtbbsHide.Main;
4444+ hiddenUris.add(value.uri);
4545+ hideRkeys[value.uri] = parseAtUri(record.uri).rkey;
4646+ }
4747+4848+ return { bannedDids, hiddenUris, banRkeys, hideRkeys };
4949+}
···11+/** Fetch a random list of BBSes from the Lightrail API, with avatars. */
22+33+import { getAvatars, getRecord, resolveIdentitiesBatch } from "./atproto";
44+import { SITE } from "./lexicon";
55+import { SERVICES } from "./shared";
66+import { is } from "@atcute/lexicons/validations";
77+import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
88+import type { XyzAtbbsSite } from "../lexicons";
99+1010+export interface DiscoveredBBS {
1111+ did: string;
1212+ handle: string;
1313+ name: string;
1414+ description: string;
1515+ avatar?: string;
1616+}
1717+1818+interface LightrailRepo {
1919+ did: string;
2020+}
2121+2222+export async function fetchDiscovery(): Promise<DiscoveredBBS[]> {
2323+ let repos: LightrailRepo[] = [];
2424+ try {
2525+ const response = await fetch(
2626+ `${SERVICES.lightrail}/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`,
2727+ );
2828+ const data = (await response.json()) as { repos: LightrailRepo[] };
2929+ repos = data.repos;
3030+ } catch {
3131+ return [];
3232+ }
3333+ if (!repos.length) return [];
3434+3535+ const shuffled = repos.sort(() => Math.random() - 0.5);
3636+ const identities = await resolveIdentitiesBatch(
3737+ shuffled.map((repo) => repo.did),
3838+ );
3939+4040+ const items: DiscoveredBBS[] = [];
4141+ for (const repo of shuffled) {
4242+ if (!(repo.did in identities)) continue;
4343+ try {
4444+ const siteRecord = await getRecord(repo.did, SITE, "self");
4545+ if (!is(siteSchema, siteRecord.value)) continue;
4646+ const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main;
4747+ items.push({
4848+ did: repo.did,
4949+ handle: identities[repo.did].handle,
5050+ name: siteValue.name || identities[repo.did].handle,
5151+ description: siteValue.description || "",
5252+ });
5353+ } catch {
5454+ continue;
5555+ }
5656+ }
5757+5858+ const avatars = await getAvatars(items.map((item) => item.did));
5959+ for (const item of items) {
6060+ item.avatar = avatars[item.did];
6161+ }
6262+6363+ return items;
6464+}
+20
web/src/lib/home.ts
···11+/** Minimal check for the dashboard: does this user run a BBS, and if so
22+ * what's it called? A full BBS fetch only happens on the BBS page itself. */
33+44+import { getRecord } from "./atproto";
55+import { SITE } from "./lexicon";
66+77+export interface HomeSysopInfo {
88+ hasBBS: boolean;
99+ bbsName: string | null;
1010+}
1111+1212+export async function fetchHomeSysopInfo(did: string): Promise<HomeSysopInfo> {
1313+ try {
1414+ const record = await getRecord(did, SITE, "self");
1515+ const value = record.value as { name?: string };
1616+ return { hasBBS: true, bbsName: value.name ?? null };
1717+ } catch {
1818+ return { hasBBS: false, bbsName: null };
1919+ }
2020+}
+41
web/src/lib/news.ts
···11+/** Fetch the list of news posts a sysop has published to their site. */
22+33+import { getBacklinks, getRecordsBatch } from "./atproto";
44+import { POST, SITE } from "./lexicon";
55+import { makeAtUri, parseAtUri } from "./util";
66+import { is } from "@atcute/lexicons/validations";
77+import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
88+import type { XyzAtbbsPost } from "../lexicons";
99+import type { NewsPost } from "./bbs";
1010+1111+export async function fetchNews(bbsDid: string): Promise<NewsPost[]> {
1212+ const siteUri = makeAtUri(bbsDid, SITE, "self");
1313+ const backlinks = await getBacklinks(siteUri, `${POST}:scope`, 50).catch(
1414+ () => null,
1515+ );
1616+ if (!backlinks) return [];
1717+1818+ const sysopRefs = backlinks.records.filter((ref) => ref.did === bbsDid);
1919+ const records = await getRecordsBatch(sysopRefs);
2020+2121+ const news: NewsPost[] = records
2222+ .filter((record) => is(postSchema, record.value))
2323+ .filter((record) => {
2424+ const value = record.value as unknown as XyzAtbbsPost.Main;
2525+ return value.title && !value.root;
2626+ })
2727+ .map((record) => {
2828+ const value = record.value as unknown as XyzAtbbsPost.Main;
2929+ return {
3030+ uri: record.uri,
3131+ rkey: parseAtUri(record.uri).rkey,
3232+ title: value.title ?? "",
3333+ body: value.body,
3434+ createdAt: value.createdAt,
3535+ attachments: value.attachments as NewsPost["attachments"],
3636+ };
3737+ });
3838+3939+ news.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4040+ return news;
4141+}
+149
web/src/lib/queries.ts
···11+/** Query-key factories. Every useQuery/useMutation in the app goes through
22+ * one of these so query keys live in one place. */
33+44+import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
55+import {
66+ fetchIdentityDoc,
77+ fetchAvatarUrl,
88+ fetchBacklinkCount,
99+} from "./atproto";
1010+import { STALE_SLOW } from "./queryClient";
1111+import { resolveBBS } from "./bbs";
1212+import { fetchNews } from "./news";
1313+import { fetchProfile } from "./profile";
1414+import { fetchMyThreads } from "./mythreads";
1515+import { fetchActivity } from "./activity";
1616+import { fetchPins } from "./pins";
1717+import { fetchDiscovery } from "./discovery";
1818+import { fetchHomeSysopInfo } from "./home";
1919+import { fetchSysopModeration } from "./sysopModeration";
2020+import { fetchBBSModeration } from "./bbsModeration";
2121+import { hydrateThreadPage } from "./boardThreads";
2222+import { fetchThreadRefs, fetchThreadRoot, hydrateReplyPage } from "./thread";
2323+import type { BacklinkRef } from "./atproto";
2424+2525+// Shared by slow-changing queries: 5-minute staleTime, and skip the
2626+// "refetch on every mount" default that live queries use.
2727+const slowQueryOpts = { staleTime: STALE_SLOW, refetchOnMount: true } as const;
2828+2929+export const bbsQuery = (handle: string) =>
3030+ queryOptions({
3131+ ...slowQueryOpts,
3232+ queryKey: ["bbs", handle] as const,
3333+ queryFn: () => resolveBBS(handle),
3434+ });
3535+3636+export const newsQuery = (bbsDid: string) =>
3737+ queryOptions({
3838+ queryKey: ["news", bbsDid] as const,
3939+ queryFn: () => fetchNews(bbsDid),
4040+ });
4141+4242+export const identityQuery = (identifier: string) =>
4343+ queryOptions({
4444+ ...slowQueryOpts,
4545+ queryKey: ["identity", identifier] as const,
4646+ queryFn: () => fetchIdentityDoc(identifier),
4747+ });
4848+4949+export const avatarQuery = (did: string) =>
5050+ queryOptions({
5151+ ...slowQueryOpts,
5252+ queryKey: ["avatar", did] as const,
5353+ queryFn: () => fetchAvatarUrl(did),
5454+ });
5555+5656+export const backlinkCountQuery = (subject: string, source: string) =>
5757+ queryOptions({
5858+ queryKey: ["backlink-count", source, subject] as const,
5959+ queryFn: () => fetchBacklinkCount(subject, source),
6060+ });
6161+6262+export const profileQuery = (handle: string) =>
6363+ queryOptions({
6464+ ...slowQueryOpts,
6565+ queryKey: ["profile", handle] as const,
6666+ queryFn: () => fetchProfile(handle),
6767+ });
6868+6969+export const myThreadsQuery = (pdsUrl: string, did: string) =>
7070+ queryOptions({
7171+ queryKey: ["my-threads", did] as const,
7272+ queryFn: () => fetchMyThreads(pdsUrl, did),
7373+ });
7474+7575+export const activityQuery = (pdsUrl: string, did: string) =>
7676+ queryOptions({
7777+ queryKey: ["activity", did] as const,
7878+ queryFn: () => fetchActivity(did, pdsUrl),
7979+ });
8080+8181+export const pinsQuery = (pdsUrl: string, did: string) =>
8282+ queryOptions({
8383+ queryKey: ["pins", did] as const,
8484+ queryFn: () => fetchPins(pdsUrl, did),
8585+ });
8686+8787+export const discoveryQuery = () =>
8888+ queryOptions({
8989+ queryKey: ["discovery"] as const,
9090+ queryFn: fetchDiscovery,
9191+ });
9292+9393+export const homeSysopQuery = (did: string) =>
9494+ queryOptions({
9595+ queryKey: ["home-sysop", did] as const,
9696+ queryFn: () => fetchHomeSysopInfo(did),
9797+ });
9898+9999+export const sysopModerationQuery = (pdsUrl: string, did: string) =>
100100+ queryOptions({
101101+ queryKey: ["sysop-moderation", did] as const,
102102+ queryFn: () => fetchSysopModeration(pdsUrl, did),
103103+ });
104104+105105+export const bbsModerationQuery = (pdsUrl: string, did: string) =>
106106+ queryOptions({
107107+ queryKey: ["bbs-moderation", did] as const,
108108+ queryFn: () => fetchBBSModeration(pdsUrl, did),
109109+ });
110110+111111+export const boardThreadsInfiniteQuery = (bbsDid: string, slug: string) =>
112112+ infiniteQueryOptions({
113113+ queryKey: ["board-threads", bbsDid, slug] as const,
114114+ queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
115115+ hydrateThreadPage(bbsDid, slug, pageParam),
116116+ initialPageParam: undefined as string | undefined,
117117+ getNextPageParam: (last) => last.cursor ?? undefined,
118118+ refetchOnMount: "always",
119119+ });
120120+121121+export const threadRefsQuery = (threadUri: string) =>
122122+ queryOptions({
123123+ queryKey: ["thread-refs", threadUri] as const,
124124+ queryFn: () => fetchThreadRefs(threadUri),
125125+ });
126126+127127+export const threadRootQuery = (did: string, tid: string) =>
128128+ queryOptions({
129129+ queryKey: ["thread-root", did, tid] as const,
130130+ queryFn: () => fetchThreadRoot(did, tid),
131131+ });
132132+133133+export const threadPageQuery = (
134134+ threadUri: string,
135135+ page: number,
136136+ pageRefs: BacklinkRef[],
137137+) =>
138138+ queryOptions({
139139+ // Fingerprint is part of the key so that when the thread-refs cache
140140+ // gets updated (new replies, deletes), this page's cache entry gets
141141+ // a new key and refetches — rather than serving a stale hydration.
142142+ queryKey: [
143143+ "thread-page",
144144+ threadUri,
145145+ page,
146146+ pageRefs.map((ref) => ref.rkey).join("/"),
147147+ ] as const,
148148+ queryFn: () => hydrateReplyPage(pageRefs),
149149+ });
+20
web/src/lib/queryClient.ts
···11+import { QueryClient } from "@tanstack/react-query";
22+33+/** Stays fresh for 30s: posts, replies, activity, counts. */
44+export const STALE_LIVE = 30 * 1000;
55+66+/** Stays fresh for 5 min: identities, avatars, site records, profiles. */
77+export const STALE_SLOW = 5 * 60 * 1000;
88+99+export const queryClient = new QueryClient({
1010+ defaultOptions: {
1111+ queries: {
1212+ staleTime: STALE_LIVE,
1313+ // Every page navigation refetches live data. Slow queries opt out
1414+ // via `refetchOnMount: true` in queries.ts.
1515+ refetchOnMount: "always",
1616+ refetchOnWindowFocus: true,
1717+ refetchOnReconnect: true,
1818+ },
1919+ },
2020+});
+129
web/src/lib/thread.ts
···11+/** Thread detail fetchers: root post, reply refs, and the hydrated
22+ * reply records for one page of the thread. */
33+44+import {
55+ getBacklinks,
66+ getRecord,
77+ getRecordsBatch,
88+ resolveIdentitiesBatch,
99+ resolveIdentity,
1010+ type BacklinkRef,
1111+} from "./atproto";
1212+import { POST } from "./lexicon";
1313+import { makeAtUri, parseAtUri } from "./util";
1414+import { recordToReply } from "./replies";
1515+import type { Reply } from "../components/post/ReplyCard";
1616+import { is } from "@atcute/lexicons/validations";
1717+import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
1818+import type { XyzAtbbsPost } from "../lexicons";
1919+2020+export interface ThreadRoot {
2121+ uri: string;
2222+ did: string;
2323+ rkey: string;
2424+ authorHandle: string;
2525+ authorPds: string;
2626+ title: string;
2727+ body: string;
2828+ createdAt: string;
2929+ boardSlug: string;
3030+ attachments?: { file: { ref: { $link: string } }; name: string }[];
3131+}
3232+3333+const MAX_REF_PAGES = 20;
3434+const REF_PAGE_SIZE = 100;
3535+3636+/** Every reply ref for the thread, oldest-first. */
3737+export async function fetchThreadRefs(
3838+ threadUri: string,
3939+): Promise<BacklinkRef[]> {
4040+ const collected: BacklinkRef[] = [];
4141+ let cursor: string | undefined;
4242+ for (let i = 0; i < MAX_REF_PAGES; i++) {
4343+ const page = await getBacklinks(
4444+ threadUri,
4545+ `${POST}:root`,
4646+ REF_PAGE_SIZE,
4747+ cursor,
4848+ );
4949+ collected.push(...page.records);
5050+ if (!page.cursor) break;
5151+ cursor = page.cursor;
5252+ }
5353+ return collected.reverse();
5454+}
5555+5656+export async function fetchThreadRoot(
5757+ did: string,
5858+ tid: string,
5959+): Promise<ThreadRoot> {
6060+ const threadRecord = await getRecord(did, POST, tid);
6161+ if (!is(postSchema, threadRecord.value)) {
6262+ throw new Error("Invalid post record");
6363+ }
6464+ const author = await resolveIdentity(did);
6565+ const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main;
6666+ const boardSlug = parseAtUri(postValue.scope).rkey;
6767+ return {
6868+ uri: threadRecord.uri,
6969+ did,
7070+ rkey: tid,
7171+ authorHandle: author.handle,
7272+ authorPds: author.pds ?? "",
7373+ title: postValue.title ?? "",
7474+ body: postValue.body,
7575+ createdAt: postValue.createdAt,
7676+ boardSlug,
7777+ attachments: postValue.attachments as ThreadRoot["attachments"],
7878+ };
7979+}
8080+8181+export function threadUriFor(did: string, tid: string): string {
8282+ return makeAtUri(did, POST, tid);
8383+}
8484+8585+export interface ReplyPage {
8686+ replies: Reply[];
8787+ /** Lookup by URI for any reply referenced as a parent — includes both
8888+ * on-page replies and off-page parents fetched separately. */
8989+ parentReplies: Record<string, Reply>;
9090+}
9191+9292+export async function hydrateReplyPage(
9393+ pageRefs: BacklinkRef[],
9494+): Promise<ReplyPage> {
9595+ if (!pageRefs.length) return { replies: [], parentReplies: {} };
9696+9797+ const records = await getRecordsBatch(pageRefs);
9898+ const authors = await resolveIdentitiesBatch(
9999+ records.map((r) => parseAtUri(r.uri).did),
100100+ );
101101+ const replies: Reply[] = records
102102+ .map((record) => recordToReply(record, authors))
103103+ .filter((reply): reply is Reply => reply !== null)
104104+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
105105+106106+ const parentReplies: Record<string, Reply> = {};
107107+ for (const reply of replies) parentReplies[reply.uri] = reply;
108108+109109+ const offPageParentUris = [
110110+ ...new Set(
111111+ replies
112112+ .map((r) => r.parent)
113113+ .filter((uri): uri is string => !!uri && !parentReplies[uri]),
114114+ ),
115115+ ];
116116+ if (offPageParentUris.length) {
117117+ const parentRefs = offPageParentUris.map((uri) => parseAtUri(uri));
118118+ const parentRecords = await getRecordsBatch(parentRefs);
119119+ const parentAuthors = await resolveIdentitiesBatch(
120120+ parentRecords.map((r) => parseAtUri(r.uri).did),
121121+ );
122122+ for (const record of parentRecords) {
123123+ const reply = recordToReply(record, parentAuthors);
124124+ if (reply) parentReplies[reply.uri] = reply;
125125+ }
126126+ }
127127+128128+ return { replies, parentReplies };
129129+}
+5
web/src/lib/util.ts
···2929 return { did: parts[2], collection: parts[3], rkey: parts[4] };
3030}
31313232+export function truncate(text: string, maxLength: number): string {
3333+ if (text.length <= maxLength) return text;
3434+ return text.substring(0, maxLength) + "...";
3535+}
3636+3237import type { Did } from "@atcute/lexicons/syntax";
33383439export function makeAtUri(
···11-import type { LoaderFunctionArgs } from "react-router-dom";
22-import { resolveBBS, type BBS } from "../../lib/bbs";
11+/** Build a page of thread summaries for a board, sorted by last activity.
22+ *
33+ * Scans recent board activity (threads + replies) from Constellation and
44+ * collects unique thread URIs in the order they appear. Since Constellation
55+ * returns newest posts first, the first time a thread URI appears is its
66+ * most recent activity — giving us bump order naturally. */
77+38import {
49 getAvatars,
510 getBacklinkCountsBatch,
···712 getRecordsBatch,
813 getRecordsByUri,
914 resolveIdentitiesBatch,
1010-} from "../../lib/atproto";
1111-import { POST, BOARD } from "../../lib/lexicon";
1212-import { makeAtUri, parseAtUri } from "../../lib/util";
1515+} from "./atproto";
1616+import { POST, BOARD } from "./lexicon";
1717+import { makeAtUri, parseAtUri } from "./util";
1318import { is } from "@atcute/lexicons/validations";
1414-import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post";
1515-import type { XyzAtbbsPost } from "../../lexicons";
1919+import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
2020+import type { XyzAtbbsPost } from "../lexicons";
16211722export interface Participant {
1823 did: string;
···3338 participants: Participant[];
3439}
35404141+export interface ThreadPageResult {
4242+ threads: ThreadItem[];
4343+ cursor: string | null;
4444+}
4545+3646const MAX_SCANS = 4;
3747const PAGE_SIZE = 25;
38483939-/**
4040- * Fetch threads for a board, sorted by last activity (bump order).
4141- *
4242- * Scans recent board activity (threads + replies) and collects unique
4343- * thread URIs in the order they appear. Since Constellation returns
4444- * newest posts first, the first time a thread URI appears is its most
4545- * recent activity — giving us bump order naturally.
4646- */
4749export async function hydrateThreadPage(
4848- bbs: BBS,
5050+ bbsDid: string,
4951 slug: string,
5052 cursor?: string,
5151-): Promise<{ threads: ThreadItem[]; cursor: string | null }> {
5252- const boardUri = makeAtUri(bbs.identity.did, BOARD, slug);
5353+): Promise<ThreadPageResult> {
5454+ const boardUri = makeAtUri(bbsDid, BOARD, slug);
53555454- // Phase 1: Scan board activity to find unique thread URIs and their posters.
5555- // Constellation returns newest-first, so first-seen activity per thread = most
5656- // recent, and Set insertion order preserves newest-first poster order.
5756 const lastActivity = new Map<string, string>();
5857 const postersByThread = new Map<string, Set<string>>();
5958 let scanCursor = cursor;
···8988 if (!scanCursor) break;
9089 }
91909292- // Phase 2: Fetch root post records for the thread URIs.
9391 const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE);
9492 const rootRecords = await getRecordsByUri(threadUris);
9593···9997 return value.title && !value.root;
10098 });
10199102102- // Phase 3: Resolve identities+avatars for every poster across all threads,
103103- // count replies, and build ThreadItems.
104100 const allDids = new Set<string>();
105101 for (const record of validRoots) {
106102 allDids.add(parseAtUri(record.uri).did);
···147143148144 return { threads, cursor: scanCursor ?? null };
149145}
150150-151151-export async function boardLoader({ params }: LoaderFunctionArgs) {
152152- const handle = params.handle!;
153153- const slug = params.slug!;
154154- const bbs = await resolveBBS(handle);
155155- const board = bbs.site.boards.find((board) => board.slug === slug);
156156- if (!board) throw new Response("Board not found", { status: 404 });
157157-158158- const { threads, cursor } = await hydrateThreadPage(bbs, slug);
159159- return { handle, bbs, board, threads, cursor };
160160-}
-32
web/src/router/loaders/home.ts
···11-import { getRecord } from "../../lib/atproto";
22-import { ensureAuthReady, getCurrentUser } from "../../lib/auth";
33-import { fetchActivity } from "../../lib/activity";
44-import { fetchPins } from "../../lib/pins";
55-import { fetchMyThreads } from "../../lib/mythreads";
66-import { SITE } from "../../lib/lexicon";
77-88-export async function homeLoader() {
99- await ensureAuthReady();
1010- const user = getCurrentUser();
1111- if (!user) return { user: null };
1212-1313- let hasBBS = false;
1414- let bbsName: string | null = null;
1515- try {
1616- const record = await getRecord(user.did, SITE, "self");
1717- hasBBS = true;
1818- const value = record.value as { name?: string };
1919- bbsName = value.name ?? null;
2020- } catch {
2121- // no site record
2222- }
2323-2424- return {
2525- user,
2626- hasBBS,
2727- bbsName,
2828- activity: fetchActivity(user.did, user.pdsUrl),
2929- pins: fetchPins(user.pdsUrl, user.did),
3030- threads: fetchMyThreads(user.pdsUrl, user.did),
3131- };
3232-}
+1-10
web/src/router/loaders/index.ts
···11-export { homeLoader } from "./home";
22-export { bbsLoader, type BBSLoaderData } from "./bbs";
33-export { profileLoader } from "./profile";
44-export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board";
55-export { threadLoader, type ThreadObj } from "./thread";
66-export { requireAuthLoader } from "./account";
77-export type { ActivityItem } from "../../lib/activity";
88-export type { PinnedBBS } from "../../lib/pins";
99-export type { MyThread } from "../../lib/mythreads";
1010-export { sysopEditLoader, sysopModerateLoader, type HiddenInfo } from "./sysop";
11+export { requireAuthLoader, requireSysopBBSLoader } from "./account";
-21
web/src/router/loaders/profile.ts
···11-import type { LoaderFunctionArgs } from "react-router-dom";
22-import { fetchProfile, type Profile } from "../../lib/profile";
33-import { fetchMyThreads, type MyThread } from "../../lib/mythreads";
44-55-export async function profileLoader({ params }: LoaderFunctionArgs) {
66- const handle = params.handle!;
77- const profile = await fetchProfile(handle);
88-99- let threads: Promise<MyThread[]> = Promise.resolve([]);
1010- if (profile) {
1111- threads = fetchMyThreads(profile.pdsUrl, profile.did);
1212- }
1313-1414- return { handle, profile, threads };
1515-}
1616-1717-export type ProfileLoaderData = {
1818- handle: string;
1919- profile: Profile | null;
2020- threads: Promise<MyThread[]>;
2121-};