@@ -122,108 +126,12 @@ export default function Account() {
)}
{tab === "bbs" && (
-
- {hasBBS ? (
-
-
-
Browse
-
View your BBS.
-
-
-
Edit
-
- Name, boards, intro.
-
-
-
-
Moderate
-
- Bans and hidden posts.
-
-
-
-
- ) : (
- <>
-
- You haven't set up a BBS yet.
-
-
- create a bbs
-
- >
- )}
-
+
)}
>
);
}
-
-function InboxList({
- items,
- userHandle,
-}: {
- items: InboxItem[];
- userHandle: string;
-}) {
- const [shown, setShown] = useState(PAGE_SIZE);
- if (items.length === 0)
- return
No messages yet.
;
- return (
-
- {items.slice(0, shown).map((m) => {
- const { did: tDid, rkey: tRkey } = parseAtUri(m.threadUri);
- const { rkey: replyRkey } = parseAtUri(m.replyUri);
- const url = `/bbs/${userHandle}/thread/${tDid}/${tRkey}#reply-${replyRkey}`;
- return (
-
-
- {m.handle}
-
- {relativeDate(m.createdAt)}
-
-
-
- {m.type === "quote"
- ? "quoted your reply"
- : `on: ${m.threadTitle}`}
-
-
{m.body}
-
- );
- })}
- {shown < items.length && (
-
-
-
- )}
-
- );
-}
diff --git a/web/src/pages/BBS.tsx b/web/src/pages/BBS.tsx
index 034bee5..dbf6e6e 100644
--- a/web/src/pages/BBS.tsx
+++ b/web/src/pages/BBS.tsx
@@ -7,7 +7,7 @@ import ComposeForm from "../components/ComposeForm";
import { NEWS, SITE } from "../lib/lexicon";
import { makeAtUri, nowIso, parseAtUri } from "../lib/util";
import * as limits from "../lib/limits";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import Localtime from "../components/Localtime";
import ListLink from "../components/ListLink";
import type { News } from "../lib/bbs";
@@ -28,7 +28,7 @@ export default function BBSPage() {
[{ label: bbs.site.name, to: `/bbs/${handle}` }],
[bbs, handle],
);
- useTitle(`${bbs.site.name} — atbbs`);
+ usePageTitle(`${bbs.site.name} — atbbs`);
if (user && bbs.site.bannedDids.has(user.did))
return (
@@ -91,12 +91,12 @@ export default function BBSPage() {
Boards
- {bbs.site.boards.map((b) => (
+ {bbs.site.boards.map((board) => (
))}
diff --git a/web/src/pages/Board.tsx b/web/src/pages/Board.tsx
index ed47e79..66824a2 100644
--- a/web/src/pages/Board.tsx
+++ b/web/src/pages/Board.tsx
@@ -7,7 +7,7 @@ import {
} from "react-router-dom";
import { useAuth } from "../lib/auth";
import { useBreadcrumb } from "../hooks/useBreadcrumb";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import { makeAtUri, parseAtUri, relativeDate } from "../lib/util";
import { BOARD } from "../lib/lexicon";
import { createThread, uploadAttachments } from "../lib/writes";
@@ -51,7 +51,7 @@ export default function BoardPage() {
const [body, setBody] = useState("");
const [files, setFiles] = useState
([]);
- useTitle(`${board.name} — ${bbs.site.name}`);
+ usePageTitle(`${board.name} — ${bbs.site.name}`);
useBreadcrumb(
[
{ label: bbs.site.name, to: `/bbs/${handle}` },
diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx
index 3031833..3f43989 100644
--- a/web/src/pages/Home.tsx
+++ b/web/src/pages/Home.tsx
@@ -4,7 +4,7 @@ import HandleInput from "../components/HandleInput";
import ListLink from "../components/ListLink";
import { resolveIdentitiesBatch } from "../lib/atproto";
import { SITE } from "../lib/lexicon";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
interface UFORecord {
did: string;
@@ -21,22 +21,22 @@ let discoveryCache: { items: Discovered[]; expires: number } | null = null;
const DISCOVERY_TTL = 5 * 60 * 1000; // 5 minutes
export default function Home() {
- const nav = useNavigate();
+ const navigate = useNavigate();
const [handle, setHandle] = useState("");
const [tab, setTab] = useState<"pip" | "uv" | "brew" | "telnet">("pip");
const [discovered, setDiscovered] = useState([]);
- useTitle("atbbs");
+ usePageTitle("atbbs");
function onSubmit(e: SyntheticEvent) {
e.preventDefault();
- const h = handle.trim();
- if (h) nav(`/bbs/${encodeURIComponent(h)}`);
+ const trimmed = handle.trim();
+ if (trimmed) navigate(`/bbs/${encodeURIComponent(trimmed)}`);
}
function onRandom() {
if (!discovered.length) return;
- const d = discovered[Math.floor(Math.random() * discovered.length)];
- nav(`/bbs/${encodeURIComponent(d.handle)}`);
+ const pick = discovered[Math.floor(Math.random() * discovered.length)];
+ navigate(`/bbs/${encodeURIComponent(pick.handle)}`);
}
useEffect(() => {
@@ -132,12 +132,12 @@ export default function Home() {
or try one of these
- {discovered.slice(0, 5).map((d) => (
+ {discovered.slice(0, 5).map((bbs) => (
))}
diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx
index af07d47..918a49a 100644
--- a/web/src/pages/Login.tsx
+++ b/web/src/pages/Login.tsx
@@ -1,6 +1,6 @@
import { useState, type SyntheticEvent } from "react";
import { useAuth } from "../lib/auth";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import HandleInput from "../components/HandleInput";
export default function Login() {
@@ -8,7 +8,7 @@ export default function Login() {
const [handle, setHandle] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
- useTitle("Login — atbbs");
+ usePageTitle("Login — atbbs");
async function onSubmit(e: SyntheticEvent) {
e.preventDefault();
diff --git a/web/src/pages/News.tsx b/web/src/pages/News.tsx
index 9df2100..ecf9078 100644
--- a/web/src/pages/News.tsx
+++ b/web/src/pages/News.tsx
@@ -1,13 +1,14 @@
import { useNavigate, useParams, useRouteLoaderData } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { useBreadcrumb } from "../hooks/useBreadcrumb";
-import { useTitle } from "../hooks/useTitle";
-import { formatFullDate, relativeDate } from "../lib/util";
+import { usePageTitle } from "../hooks/usePageTitle";
import { NEWS } from "../lib/lexicon";
import { deleteRecord } from "../lib/writes";
import type { BBSLoaderData } from "../router/loaders";
import AttachmentLink from "../components/AttachmentLink";
+import PostActions from "../components/PostActions";
import PostBody from "../components/PostBody";
+import PostMeta from "../components/PostMeta";
export default function NewsPage() {
const { handle, tid } = useParams();
@@ -24,7 +25,7 @@ export default function NewsPage() {
],
[bbs, handle, tid],
);
- useTitle(
+ usePageTitle(
item ? `${item.title} — ${bbs.site.name}` : `News — ${bbs.site.name}`,
);
@@ -32,7 +33,7 @@ export default function NewsPage() {
return News post not found.
;
}
- const isSysop = user && user.did === bbs.identity.did;
+ const isSysop = !!(user && user.did === bbs.identity.did);
async function onDelete() {
if (!agent || !tid) return;
@@ -44,36 +45,24 @@ export default function NewsPage() {
return (
-
- {handle}
- ·
-
-
- {isSysop && (
-
- )}
+
+
{item.title}
{item.body}
{item.attachments && item.attachments.length > 0 && (
- {item.attachments.map((a, i) => (
+ {item.attachments.map((attachment, index) => (
))}
diff --git a/web/src/pages/OAuthCallback.tsx b/web/src/pages/OAuthCallback.tsx
index 548fc76..08b6ca8 100644
--- a/web/src/pages/OAuthCallback.tsx
+++ b/web/src/pages/OAuthCallback.tsx
@@ -4,20 +4,20 @@ import { completeAuthCallback, takePostLoginRedirect } from "../lib/auth";
/** Handles the OAuth redirect: exchanges params for a session. */
export default function OAuthCallback() {
- const nav = useNavigate();
+ const navigate = useNavigate();
const [error, setError] = useState(null);
useEffect(() => {
completeAuthCallback()
.then(() => {
const dest = takePostLoginRedirect() ?? "/";
- nav(dest, { replace: true });
+ navigate(dest, { replace: true });
})
.catch((e) => {
console.error(e);
setError(e?.message ?? "Sign-in failed.");
});
- }, [nav]);
+ }, [navigate]);
if (error) return {error}
;
return Signing in…
;
diff --git a/web/src/pages/SysopCreate.tsx b/web/src/pages/SysopCreate.tsx
index 4670169..a25ce81 100644
--- a/web/src/pages/SysopCreate.tsx
+++ b/web/src/pages/SysopCreate.tsx
@@ -4,7 +4,7 @@ import { useAuth } from "../lib/auth";
import { putBoard, putSite } from "../lib/writes";
import { nowIso } from "../lib/util";
import * as limits from "../lib/limits";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import { Input, Textarea, Button } from "../components/Form";
import BoardRowEditor, { type BoardRow } from "../components/BoardRowEditor";
import type { AuthUser } from "../lib/auth";
@@ -26,32 +26,32 @@ export default function SysopCreate() {
]);
const [error, setError] = useState(null);
- useTitle("Create BBS — atbbs");
+ usePageTitle("Create BBS — atbbs");
async function onSubmit(e: SyntheticEvent) {
e.preventDefault();
if (!agent) return;
const cleanBoards = boards
- .map((b) => ({
- slug: b.slug.trim(),
- name: b.name.trim(),
- desc: b.desc.trim(),
+ .map((board) => ({
+ slug: board.slug.trim(),
+ name: board.name.trim(),
+ desc: board.desc.trim(),
}))
- .filter((b) => b.slug);
+ .filter((board) => board.slug);
if (!name.trim() || !cleanBoards.length) {
setError("Name and at least one board are required.");
return;
}
const now = nowIso();
try {
- for (const b of cleanBoards) {
- await putBoard(agent, b.slug, b.name || b.slug, b.desc, now);
+ for (const board of cleanBoards) {
+ await putBoard(agent, board.slug, board.name || board.slug, board.desc, now);
}
await putSite(agent, {
name: name.trim(),
description: description.trim(),
intro,
- boards: cleanBoards.map((b) => b.slug),
+ boards: cleanBoards.map((board) => board.slug),
createdAt: now,
});
navigate(`/bbs/${user.handle}`);
diff --git a/web/src/pages/SysopEdit.tsx b/web/src/pages/SysopEdit.tsx
index aa832d2..f5faaec 100644
--- a/web/src/pages/SysopEdit.tsx
+++ b/web/src/pages/SysopEdit.tsx
@@ -4,7 +4,7 @@ import { useAuth } from "../lib/auth";
import { putBoard, putSite } from "../lib/writes";
import { nowIso } from "../lib/util";
import * as limits from "../lib/limits";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import { Input, Textarea, Button } from "../components/Form";
import BoardRowEditor, { type BoardRow } from "../components/BoardRowEditor";
import type { BBS } from "../lib/bbs";
@@ -24,36 +24,36 @@ export default function SysopEdit() {
const [description, setDescription] = useState(bbs.site.description);
const [intro, setIntro] = useState(bbs.site.intro);
const [boards, setBoards] = useState(
- bbs.site.boards.map((b) => ({
- slug: b.slug,
- name: b.name,
- desc: b.description,
+ bbs.site.boards.map((board) => ({
+ slug: board.slug,
+ name: board.name,
+ desc: board.description,
})),
);
const [error, setError] = useState(null);
- useTitle("Edit BBS — atbbs");
+ usePageTitle("Edit BBS — atbbs");
async function onSubmit(e: SyntheticEvent) {
e.preventDefault();
if (!agent || !name.trim()) return;
const cleanBoards = boards
- .map((b) => ({
- slug: b.slug.trim(),
- name: b.name.trim(),
- desc: b.desc.trim(),
+ .map((board) => ({
+ slug: board.slug.trim(),
+ name: board.name.trim(),
+ desc: board.desc.trim(),
}))
- .filter((b) => b.slug);
+ .filter((board) => board.slug);
const now = nowIso();
try {
- for (const b of cleanBoards) {
- await putBoard(agent, b.slug, b.name || b.slug, b.desc, now);
+ for (const board of cleanBoards) {
+ await putBoard(agent, board.slug, board.name || board.slug, board.desc, now);
}
await putSite(agent, {
name: name.trim(),
description: description.trim(),
intro,
- boards: cleanBoards.map((b) => b.slug),
+ boards: cleanBoards.map((board) => board.slug),
createdAt: bbs.site.createdAt || now,
updatedAt: now,
});
diff --git a/web/src/pages/SysopModerate.tsx b/web/src/pages/SysopModerate.tsx
index c0a8f00..e72a7e0 100644
--- a/web/src/pages/SysopModerate.tsx
+++ b/web/src/pages/SysopModerate.tsx
@@ -5,7 +5,7 @@ import { resolveIdentity } from "../lib/atproto";
import { BAN, HIDE } from "../lib/lexicon";
import { invalidateBBSCache } from "../lib/bbs";
import HandleInput from "../components/HandleInput";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import { createBan, createHide, deleteRecord } from "../lib/writes";
import type { BBS } from "../lib/bbs";
import type { AuthUser } from "../lib/auth";
@@ -27,7 +27,7 @@ export default function SysopModerate() {
const revalidator = useRevalidator();
const [identifier, setIdentifier] = useState("");
const [hideUri, setHideUri] = useState("");
- useTitle("Moderate BBS — atbbs");
+ usePageTitle("Moderate BBS — atbbs");
async function ban() {
if (!agent) return;
diff --git a/web/src/pages/Thread.tsx b/web/src/pages/Thread.tsx
index 0884c77..7490104 100644
--- a/web/src/pages/Thread.tsx
+++ b/web/src/pages/Thread.tsx
@@ -7,7 +7,7 @@ import {
} from "react-router-dom";
import { useAuth } from "../lib/auth";
import { useBreadcrumb } from "../hooks/useBreadcrumb";
-import { useTitle } from "../hooks/useTitle";
+import { usePageTitle } from "../hooks/usePageTitle";
import { useThreadReplies } from "../hooks/useThreadReplies";
import { THREAD, REPLY } from "../lib/lexicon";
import { makeAtUri, parseAtUri } from "../lib/util";
@@ -23,7 +23,7 @@ import type { BBSLoaderData, ThreadObj } from "../router/loaders";
import PageNav from "../components/PageNav";
import ReplyCard, { type Reply } from "../components/ReplyCard";
import ComposeForm from "../components/ComposeForm";
-import ThreadHeader from "../components/ThreadHeader";
+import ThreadCard from "../components/ThreadCard";
interface LoaderData {
handle: string;
@@ -70,7 +70,7 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) {
);
const [posting, setPosting] = useState(false);
- useTitle(`${thread.title} — ${bbs.site.name}`);
+ usePageTitle(`${thread.title} — ${bbs.site.name}`);
useBreadcrumb(buildBreadcrumb(bbs, thread, handle), [bbs, thread, handle]);
const isSysop = user && user.did === bbs.identity.did;
@@ -147,13 +147,13 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) {
return (
<>
- onBan(thread.did)}
- onHideThread={() => onHide(thread.uri)}
+ onDelete={onDeleteThread}
+ onBan={() => onBan(thread.did)}
+ onHide={() => onHide(thread.uri)}
/>
{totalPages > 1 && (
@@ -217,7 +217,7 @@ function buildBreadcrumb(
thread: ThreadObj,
handle: string,
) {
- const board = bbs.site.boards.find((b) => b.slug === thread.boardSlug);
+ const board = bbs.site.boards.find((board) => board.slug === thread.boardSlug);
return [
{ label: bbs.site.name, to: `/bbs/${handle}` },
...(board
diff --git a/web/src/router/loaders.ts b/web/src/router/loaders.ts
deleted file mode 100644
index 7a64760..0000000
--- a/web/src/router/loaders.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-/** Route loaders. Pages read these via useLoaderData(). */
-
-import { redirect, type LoaderFunctionArgs } from "react-router-dom";
-import { ensureAuthReady, getCurrentUser } from "../lib/auth";
-import { resolveBBS, type BBS } from "../lib/bbs";
-import { fetchInbox } from "../lib/inbox";
-import {
- getRecord,
- getRecordByUri,
- getBacklinks,
- getRecordsBatch,
- listRecords,
- resolveIdentitiesBatch,
- resolveIdentity,
- type ATRecord,
- type BacklinkRef,
-} from "../lib/atproto";
-import { SITE, THREAD, REPLY, BAN, HIDE, BOARD } from "../lib/lexicon";
-import { makeAtUri, parseAtUri } from "../lib/util";
-import { is } from "@atcute/lexicons/validations";
-import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread";
-import { mainSchema as banSchema } from "../lexicons/types/xyz/atboards/ban";
-import { mainSchema as hideSchema } from "../lexicons/types/xyz/atboards/hide";
-import type {
- XyzAtboardsThread,
- XyzAtboardsBan,
- XyzAtboardsHide,
-} from "../lexicons";
-
-// --- Auth guard (shared by all protected loaders) ---
-
-async function requireAuth() {
- await ensureAuthReady();
- const user = getCurrentUser();
- if (!user) throw redirect("/login");
- return user;
-}
-
-// --- BBS parent ---
-
-export async function bbsLoader({ params }: LoaderFunctionArgs) {
- const handle = params.handle!;
- const bbs = await resolveBBS(handle);
- return { handle, bbs };
-}
-
-export type BBSLoaderData = { handle: string; bbs: BBS };
-
-// --- Board ---
-
-export interface ThreadItem {
- uri: string;
- did: string;
- rkey: string;
- handle: string;
- title: string;
- body: string;
- createdAt: string;
-}
-
-/** Fetch one page of threads for a board, hydrated and filtered. */
-export async function hydrateThreadPage(
- bbs: BBS,
- slug: string,
- cursor?: string,
-): Promise<{ threads: ThreadItem[]; cursor: string | null }> {
- const boardUri = makeAtUri(bbs.identity.did, BOARD, slug);
- const backlinks = await getBacklinks(boardUri, `${THREAD}:board`, 50, cursor);
- const records = await getRecordsBatch(backlinks.records);
- const filtered = records.filter((r) => {
- const { did } = parseAtUri(r.uri);
- return (
- !bbs.site.bannedDids.has(did) &&
- !bbs.site.hiddenPosts.has(r.uri) &&
- is(threadSchema, r.value)
- );
- });
- const authors = await resolveIdentitiesBatch(
- filtered.map((r) => parseAtUri(r.uri).did),
- );
- const threads: ThreadItem[] = filtered
- .filter((r) => parseAtUri(r.uri).did in authors)
- .map((r: ATRecord) => {
- const { did, rkey } = parseAtUri(r.uri);
- const v = r.value as unknown as XyzAtboardsThread.Main;
- return {
- uri: r.uri,
- did,
- rkey,
- handle: authors[did].handle,
- title: v.title,
- body: v.body,
- createdAt: v.createdAt,
- };
- })
- .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
- return { threads, cursor: backlinks.cursor ?? null };
-}
-
-export async function boardLoader({ params }: LoaderFunctionArgs) {
- const handle = params.handle!;
- const slug = params.slug!;
- const bbs = await resolveBBS(handle);
- const board = bbs.site.boards.find((b) => b.slug === slug);
- if (!board) throw new Response("Board not found", { status: 404 });
-
- const { threads, cursor } = await hydrateThreadPage(bbs, slug);
- return { handle, bbs, board, threads, cursor };
-}
-
-// --- Thread ---
-
-export interface ThreadObj {
- uri: string;
- did: string;
- rkey: string;
- authorHandle: string;
- authorPds: string;
- title: string;
- body: string;
- createdAt: string;
- boardSlug: string;
- attachments?: { file: { ref: { $link: string } }; name: string }[];
-}
-
-export async function threadLoader({ params }: LoaderFunctionArgs) {
- const handle = params.handle!;
- const did = params.did!;
- const tid = params.tid!;
-
- const threadUri = makeAtUri(did, THREAD, tid);
- const [bbs, tr, author, allRefs] = await Promise.all([
- resolveBBS(handle),
- getRecord(did, THREAD, tid),
- resolveIdentity(did),
- collectAllReplyRefs(threadUri),
- ]);
- if (!is(threadSchema, tr.value)) {
- throw new Response("Invalid thread record", { status: 404 });
- }
- const tv = tr.value as unknown as XyzAtboardsThread.Main;
- const boardSlug = parseAtUri(tv.board).rkey;
- const thread: ThreadObj = {
- uri: tr.uri,
- did,
- rkey: tid,
- authorHandle: author.handle,
- authorPds: author.pds ?? "",
- title: tv.title,
- body: tv.body,
- createdAt: tv.createdAt,
- boardSlug,
- attachments: tv.attachments as ThreadObj["attachments"],
- };
-
- return { handle, bbs, thread, allRefs };
-}
-
-// --- Account ---
-
-export type { InboxItem } from "../lib/inbox";
-
-/** Collect all reply refs, paginating Constellation in chunks of 100. */
-async function collectAllReplyRefs(threadUri: string): Promise {
- const collected: BacklinkRef[] = [];
- let cursor: string | undefined;
- for (let i = 0; i < 20; i++) {
- const page = await getBacklinks(threadUri, `${REPLY}:subject`, 100, cursor);
- collected.push(...page.records);
- if (!page.cursor) break;
- cursor = page.cursor;
- }
- return collected.reverse(); // oldest first
-}
-
-export async function accountLoader() {
- const user = await requireAuth();
-
- // Probe site record (fast — keep awaited so the page can render with it)
- let hasBBS = false;
- let bbsName: string | null = null;
- try {
- const r = await getRecord(user.did, SITE, "self");
- hasBBS = true;
- const sv = r.value as unknown as { name?: string };
- bbsName = sv.name ?? user.handle;
- } catch {
- // no site
- }
-
- // Inbox lookup is slow — return the promise unawaited so the page renders
- // immediately and items stream in via . (v7 auto-defers promises.)
- const itemsPromise = fetchInbox(user.did, user.pdsUrl);
- return { user, hasBBS, bbsName, items: itemsPromise };
-}
-
-// --- Sysop ---
-
-export async function requireAuthLoader() {
- return { user: await requireAuth() };
-}
-
-export async function sysopEditLoader() {
- const user = await requireAuth();
- try {
- const bbs = await resolveBBS(user.handle);
- return { user, bbs };
- } catch {
- throw redirect("/account/create");
- }
-}
-
-export interface HiddenInfo {
- uri: string;
- handle: string;
- title: string;
- body: string;
-}
-
-/** Build a map from a record field value to its rkey, for deletion. */
-function buildRkeyMap(
- records: { uri: string; value: Record }[],
- schema: Parameters[0],
- getKey: (v: T) => string,
-): Record {
- const map: Record = {};
- for (const r of records) {
- if (!is(schema, r.value)) continue;
- map[getKey(r.value as unknown as T)] = parseAtUri(r.uri).rkey;
- }
- return map;
-}
-
-async function hydrateHiddenPosts(uris: Set): Promise {
- const hidden: HiddenInfo[] = [];
- for (const uri of uris) {
- const did = parseAtUri(uri).did;
- let handle = did;
- try {
- handle = (await resolveIdentity(did)).handle;
- } catch {}
- try {
- const rec = await getRecordByUri(uri);
- const v = rec.value as unknown as { title?: string; body?: string };
- hidden.push({
- uri,
- handle,
- title: v.title ?? "",
- body: (v.body ?? "").substring(0, 100),
- });
- } catch {
- hidden.push({ uri, handle, title: "", body: uri });
- }
- }
- return hidden;
-}
-
-export async function sysopModerateLoader() {
- const user = await requireAuth();
-
- let bbs: BBS;
- try {
- bbs = await resolveBBS(user.handle);
- } catch {
- throw redirect("/account/create");
- }
-
- const [banRecs, hideRecs] = await Promise.all([
- listRecords(user.pdsUrl, user.did, BAN),
- listRecords(user.pdsUrl, user.did, HIDE),
- ]);
-
- const banRkeys = buildRkeyMap(
- banRecs,
- banSchema,
- (v) => v.did,
- );
- const hideRkeys = buildRkeyMap(
- hideRecs,
- hideSchema,
- (v) => v.uri,
- );
-
- let bannedHandles: Record = {};
- if (bbs.site.bannedDids.size) {
- try {
- const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]);
- for (const did of bbs.site.bannedDids)
- bannedHandles[did] = authors[did]?.handle ?? did;
- } catch {
- for (const did of bbs.site.bannedDids) bannedHandles[did] = did;
- }
- }
-
- const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts);
-
- return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden };
-}
diff --git a/web/src/router/loaders/account.ts b/web/src/router/loaders/account.ts
new file mode 100644
index 0000000..f5f6658
--- /dev/null
+++ b/web/src/router/loaders/account.ts
@@ -0,0 +1,28 @@
+import { getRecord } from "../../lib/atproto";
+import { fetchInbox } from "../../lib/inbox";
+import { SITE } from "../../lib/lexicon";
+import { requireAuth } from "./auth";
+
+export type { InboxItem } from "../../lib/inbox";
+
+export async function accountLoader() {
+ const user = await requireAuth();
+
+ let hasBBS = false;
+ let bbsName: string | null = null;
+ try {
+ const siteRecord = await getRecord(user.did, SITE, "self");
+ hasBBS = true;
+ const siteValue = siteRecord.value as unknown as { name?: string };
+ bbsName = siteValue.name ?? user.handle;
+ } catch {
+ // no site
+ }
+
+ const itemsPromise = fetchInbox(user.did, user.pdsUrl);
+ return { user, hasBBS, bbsName, items: itemsPromise };
+}
+
+export async function requireAuthLoader() {
+ return { user: await requireAuth() };
+}
diff --git a/web/src/router/loaders/auth.ts b/web/src/router/loaders/auth.ts
new file mode 100644
index 0000000..99a26f7
--- /dev/null
+++ b/web/src/router/loaders/auth.ts
@@ -0,0 +1,9 @@
+import { redirect } from "react-router-dom";
+import { ensureAuthReady, getCurrentUser } from "../../lib/auth";
+
+export async function requireAuth() {
+ await ensureAuthReady();
+ const user = getCurrentUser();
+ if (!user) throw redirect("/login");
+ return user;
+}
diff --git a/web/src/router/loaders/bbs.ts b/web/src/router/loaders/bbs.ts
new file mode 100644
index 0000000..2eb1001
--- /dev/null
+++ b/web/src/router/loaders/bbs.ts
@@ -0,0 +1,10 @@
+import type { LoaderFunctionArgs } from "react-router-dom";
+import { resolveBBS, type BBS } from "../../lib/bbs";
+
+export async function bbsLoader({ params }: LoaderFunctionArgs) {
+ const handle = params.handle!;
+ const bbs = await resolveBBS(handle);
+ return { handle, bbs };
+}
+
+export type BBSLoaderData = { handle: string; bbs: BBS };
diff --git a/web/src/router/loaders/board.ts b/web/src/router/loaders/board.ts
new file mode 100644
index 0000000..3927fdb
--- /dev/null
+++ b/web/src/router/loaders/board.ts
@@ -0,0 +1,72 @@
+import type { LoaderFunctionArgs } from "react-router-dom";
+import { resolveBBS, type BBS } from "../../lib/bbs";
+import {
+ getBacklinks,
+ getRecordsBatch,
+ resolveIdentitiesBatch,
+ type ATRecord,
+} from "../../lib/atproto";
+import { THREAD, BOARD } from "../../lib/lexicon";
+import { makeAtUri, parseAtUri } from "../../lib/util";
+import { is } from "@atcute/lexicons/validations";
+import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread";
+import type { XyzAtboardsThread } from "../../lexicons";
+
+export interface ThreadItem {
+ uri: string;
+ did: string;
+ rkey: string;
+ handle: string;
+ title: string;
+ body: string;
+ createdAt: string;
+}
+
+export async function hydrateThreadPage(
+ bbs: BBS,
+ slug: string,
+ cursor?: string,
+): Promise<{ threads: ThreadItem[]; cursor: string | null }> {
+ const boardUri = makeAtUri(bbs.identity.did, BOARD, slug);
+ const backlinks = await getBacklinks(boardUri, `${THREAD}:board`, 50, cursor);
+ const records = await getRecordsBatch(backlinks.records);
+ const filtered = records.filter((record) => {
+ const { did } = parseAtUri(record.uri);
+ return (
+ !bbs.site.bannedDids.has(did) &&
+ !bbs.site.hiddenPosts.has(record.uri) &&
+ is(threadSchema, record.value)
+ );
+ });
+ const authors = await resolveIdentitiesBatch(
+ filtered.map((record) => parseAtUri(record.uri).did),
+ );
+ const threads: ThreadItem[] = filtered
+ .filter((record) => parseAtUri(record.uri).did in authors)
+ .map((record: ATRecord) => {
+ const { did, rkey } = parseAtUri(record.uri);
+ const value = record.value as unknown as XyzAtboardsThread.Main;
+ return {
+ uri: record.uri,
+ did,
+ rkey,
+ handle: authors[did].handle,
+ title: value.title,
+ body: value.body,
+ createdAt: value.createdAt,
+ };
+ })
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ return { threads, cursor: backlinks.cursor ?? null };
+}
+
+export async function boardLoader({ params }: LoaderFunctionArgs) {
+ const handle = params.handle!;
+ const slug = params.slug!;
+ const bbs = await resolveBBS(handle);
+ const board = bbs.site.boards.find((board) => board.slug === slug);
+ if (!board) throw new Response("Board not found", { status: 404 });
+
+ const { threads, cursor } = await hydrateThreadPage(bbs, slug);
+ return { handle, bbs, board, threads, cursor };
+}
diff --git a/web/src/router/loaders/index.ts b/web/src/router/loaders/index.ts
new file mode 100644
index 0000000..d382da8
--- /dev/null
+++ b/web/src/router/loaders/index.ts
@@ -0,0 +1,13 @@
+export { bbsLoader, type BBSLoaderData } from "./bbs";
+export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board";
+export { threadLoader, type ThreadObj } from "./thread";
+export {
+ accountLoader,
+ requireAuthLoader,
+ type InboxItem,
+} from "./account";
+export {
+ sysopEditLoader,
+ sysopModerateLoader,
+ type HiddenInfo,
+} from "./sysop";
diff --git a/web/src/router/loaders/sysop.ts b/web/src/router/loaders/sysop.ts
new file mode 100644
index 0000000..6ab1781
--- /dev/null
+++ b/web/src/router/loaders/sysop.ts
@@ -0,0 +1,111 @@
+import { redirect } from "react-router-dom";
+import { resolveBBS, type BBS } from "../../lib/bbs";
+import {
+ getRecordByUri,
+ listRecords,
+ resolveIdentitiesBatch,
+ resolveIdentity,
+} from "../../lib/atproto";
+import { BAN, HIDE } from "../../lib/lexicon";
+import { parseAtUri } from "../../lib/util";
+import { is } from "@atcute/lexicons/validations";
+import { mainSchema as banSchema } from "../../lexicons/types/xyz/atboards/ban";
+import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atboards/hide";
+import type { XyzAtboardsBan, XyzAtboardsHide } from "../../lexicons";
+import { requireAuth } from "./auth";
+
+export interface HiddenInfo {
+ uri: string;
+ handle: string;
+ title: string;
+ body: string;
+}
+
+function buildRkeyMap(
+ records: { uri: string; value: Record }[],
+ schema: Parameters[0],
+ getKey: (value: T) => string,
+): Record {
+ const map: Record = {};
+ for (const record of records) {
+ if (!is(schema, record.value)) continue;
+ map[getKey(record.value as unknown as T)] = parseAtUri(record.uri).rkey;
+ }
+ return map;
+}
+
+async function hydrateHiddenPosts(uris: Set): Promise {
+ const hidden: HiddenInfo[] = [];
+ for (const uri of uris) {
+ const did = parseAtUri(uri).did;
+ let handle = did;
+ try {
+ handle = (await resolveIdentity(did)).handle;
+ } catch {}
+ try {
+ const record = await getRecordByUri(uri);
+ const value = record.value as unknown as { title?: string; body?: string };
+ hidden.push({
+ uri,
+ handle,
+ title: value.title ?? "",
+ body: (value.body ?? "").substring(0, 100),
+ });
+ } catch {
+ hidden.push({ uri, handle, title: "", body: uri });
+ }
+ }
+ return hidden;
+}
+
+export async function sysopEditLoader() {
+ const user = await requireAuth();
+ try {
+ const bbs = await resolveBBS(user.handle);
+ return { user, bbs };
+ } catch {
+ throw redirect("/account/create");
+ }
+}
+
+export async function sysopModerateLoader() {
+ const user = await requireAuth();
+
+ let bbs: BBS;
+ try {
+ bbs = await resolveBBS(user.handle);
+ } catch {
+ throw redirect("/account/create");
+ }
+
+ const [banRecs, hideRecs] = await Promise.all([
+ listRecords(user.pdsUrl, user.did, BAN),
+ listRecords(user.pdsUrl, user.did, HIDE),
+ ]);
+
+ const banRkeys = buildRkeyMap(
+ banRecs,
+ banSchema,
+ (ban) => ban.did,
+ );
+ const hideRkeys = buildRkeyMap(
+ hideRecs,
+ hideSchema,
+ (hide) => hide.uri,
+ );
+
+ let bannedHandles: Record = {};
+ if (bbs.site.bannedDids.size) {
+ try {
+ const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]);
+ for (const did of bbs.site.bannedDids)
+ bannedHandles[did] = authors[did]?.handle ?? did;
+ } catch {
+ for (const did of bbs.site.bannedDids) bannedHandles[did] = did;
+ }
+ }
+
+ const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts);
+
+ return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden };
+}
diff --git a/web/src/router/loaders/thread.ts b/web/src/router/loaders/thread.ts
new file mode 100644
index 0000000..bb6206c
--- /dev/null
+++ b/web/src/router/loaders/thread.ts
@@ -0,0 +1,71 @@
+import type { LoaderFunctionArgs } from "react-router-dom";
+import { resolveBBS } from "../../lib/bbs";
+import {
+ getRecord,
+ getBacklinks,
+ resolveIdentity,
+ type BacklinkRef,
+} from "../../lib/atproto";
+import { THREAD, REPLY } from "../../lib/lexicon";
+import { makeAtUri, parseAtUri } from "../../lib/util";
+import { is } from "@atcute/lexicons/validations";
+import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread";
+import type { XyzAtboardsThread } from "../../lexicons";
+
+export interface ThreadObj {
+ uri: string;
+ did: string;
+ rkey: string;
+ authorHandle: string;
+ authorPds: string;
+ title: string;
+ body: string;
+ createdAt: string;
+ boardSlug: string;
+ attachments?: { file: { ref: { $link: string } }; name: string }[];
+}
+
+async function collectAllReplyRefs(threadUri: string): Promise {
+ const collected: BacklinkRef[] = [];
+ let cursor: string | undefined;
+ for (let i = 0; i < 20; i++) {
+ const page = await getBacklinks(threadUri, `${REPLY}:subject`, 100, cursor);
+ collected.push(...page.records);
+ if (!page.cursor) break;
+ cursor = page.cursor;
+ }
+ return collected.reverse(); // oldest first
+}
+
+export async function threadLoader({ params }: LoaderFunctionArgs) {
+ const handle = params.handle!;
+ const did = params.did!;
+ const tid = params.tid!;
+
+ const threadUri = makeAtUri(did, THREAD, tid);
+ const [bbs, threadRecord, author, allRefs] = await Promise.all([
+ resolveBBS(handle),
+ getRecord(did, THREAD, tid),
+ resolveIdentity(did),
+ collectAllReplyRefs(threadUri),
+ ]);
+ if (!is(threadSchema, threadRecord.value)) {
+ throw new Response("Invalid thread record", { status: 404 });
+ }
+ const threadValue = threadRecord.value as unknown as XyzAtboardsThread.Main;
+ const boardSlug = parseAtUri(threadValue.board).rkey;
+ const thread: ThreadObj = {
+ uri: threadRecord.uri,
+ did,
+ rkey: tid,
+ authorHandle: author.handle,
+ authorPds: author.pds ?? "",
+ title: threadValue.title,
+ body: threadValue.body,
+ createdAt: threadValue.createdAt,
+ boardSlug,
+ attachments: threadValue.attachments as ThreadObj["attachments"],
+ };
+
+ return { handle, bbs, thread, allRefs };
+}