diff --git a/web/package-lock.json b/web/package-lock.json index 20f09a7..77290be 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,8 @@ "@atcute/atproto": "^3.1.11", "@atcute/client": "^4.2.1", "@atcute/oauth-browser-client": "^3.0.0", + "@tanstack/react-query": "^5.100.1", + "@tanstack/react-query-devtools": "^5.100.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -1336,6 +1338,59 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.100.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.1.tgz", + "integrity": "sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.1.tgz", + "integrity": "sha512-jZLV2l7XjYxXCrXHj9pj15gZuY8Te+idoSPS2hIh3+SxOd20Gn0rfUoqEw9vc+us/b16hi0/DWqpzx9O1ZsyIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.1.tgz", + "integrity": "sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.1.tgz", + "integrity": "sha512-JuLinBUl/BlZhm0WVX83fJgE2a3YSbuEdxf3fgP+THg92hX7YfwuH5DzT35a6sL/rifZsPr0yJ9itB6jDOcdRg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.1", + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/web/package.json b/web/package.json index f1cf196..e9d4fac 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,8 @@ "@atcute/atproto": "^3.1.11", "@atcute/client": "^4.2.1", "@atcute/oauth-browser-client": "^3.0.0", + "@tanstack/react-query": "^5.100.1", + "@tanstack/react-query-devtools": "^5.100.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/web/src/components/dashboard/DialBBS.tsx b/web/src/components/dashboard/DialBBS.tsx index a34a1eb..be3aa18 100644 --- a/web/src/components/dashboard/DialBBS.tsx +++ b/web/src/components/dashboard/DialBBS.tsx @@ -6,7 +6,7 @@ import IdentityRow from "../IdentityRow"; import { Button } from "../form/Form"; import { useDropdown } from "../../hooks/useDropdown"; import { useResolvedBBS } from "../../hooks/useResolvedBBS"; -import type { DiscoveredBBS } from "../../hooks/useDiscovery"; +import type { DiscoveredBBS } from "../../lib/discovery"; export interface Suggestion { to: string; diff --git a/web/src/components/dashboard/DiscoveryList.tsx b/web/src/components/dashboard/DiscoveryList.tsx index f54bb43..54e845a 100644 --- a/web/src/components/dashboard/DiscoveryList.tsx +++ b/web/src/components/dashboard/DiscoveryList.tsx @@ -1,5 +1,5 @@ import ListLink from "../nav/ListLink"; -import type { DiscoveredBBS } from "../../hooks/useDiscovery"; +import type { DiscoveredBBS } from "../../lib/discovery"; interface DiscoveryListProps { discovered: DiscoveredBBS[]; diff --git a/web/src/components/dashboard/MyThreadList.tsx b/web/src/components/dashboard/MyThreadList.tsx index c20820f..556b909 100644 --- a/web/src/components/dashboard/MyThreadList.tsx +++ b/web/src/components/dashboard/MyThreadList.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { ChevronDown } from "lucide-react"; import { Link } from "react-router-dom"; import { parseAtUri, formatFullDate, relativeDate } from "../../lib/util"; -import type { MyThread } from "../../router/loaders"; +import type { MyThread } from "../../lib/mythreads"; const PAGE_SIZE = 10; diff --git a/web/src/components/dashboard/PinnedList.tsx b/web/src/components/dashboard/PinnedList.tsx index f7466fa..dae091f 100644 --- a/web/src/components/dashboard/PinnedList.tsx +++ b/web/src/components/dashboard/PinnedList.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { ChevronDown } from "lucide-react"; import ListLink from "../nav/ListLink"; -import type { PinnedBBS } from "../../router/loaders"; +import type { PinnedBBS } from "../../lib/pins"; const PAGE_SIZE = 5; diff --git a/web/src/components/layout/ErrorBoundary.tsx b/web/src/components/layout/ErrorBoundary.tsx new file mode 100644 index 0000000..6797221 --- /dev/null +++ b/web/src/components/layout/ErrorBoundary.tsx @@ -0,0 +1,29 @@ +import { Component, type ReactNode } from "react"; +import ErrorPage from "./ErrorPage"; + +interface Props { + children: ReactNode; +} + +interface State { + error: unknown; +} + +export default class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: unknown): State { + return { error }; + } + + componentDidUpdate(prev: Props) { + if (prev.children !== this.props.children && this.state.error) { + this.setState({ error: null }); + } + } + + render() { + if (this.state.error) return ; + return this.props.children; + } +} diff --git a/web/src/components/layout/ErrorPage.tsx b/web/src/components/layout/ErrorPage.tsx index f35a77f..910e2fe 100644 --- a/web/src/components/layout/ErrorPage.tsx +++ b/web/src/components/layout/ErrorPage.tsx @@ -1,18 +1,17 @@ -import { isRouteErrorResponse, useRouteError } from "react-router-dom"; -import { BBSNotFoundError, NoBBSError, NetworkError } from "../../lib/bbs"; +import { BBSNotFoundError, NoBBSError } from "../../lib/bbs"; import { useAuth } from "../../lib/auth"; import { ActionLink } from "../nav/ActionButton"; -export default function ErrorPage() { - const error = useRouteError(); +interface ErrorPageProps { + error: unknown; +} + +export default function ErrorPage({ error }: ErrorPageProps) { const { user } = useAuth(); let title = "Something went wrong."; let detail: string | null = null; - let action: { to: string; label: string } = { - to: "/", - label: "← back to home", - }; + let action = { to: "/", label: "← back to home" }; if (error instanceof BBSNotFoundError) { title = "Community not found."; @@ -26,13 +25,6 @@ export default function ErrorPage() { "This account isn't running a community yet. Is this you? Log in to start one."; action = { to: "/?login=1", label: "log in" }; } - } else if (error instanceof NetworkError) { - title = "Couldn't reach the network."; - detail = "Try again in a moment."; - } else if (isRouteErrorResponse(error)) { - if (error.status === 404) title = "Not found."; - else title = error.statusText || `Error ${error.status}`; - if (typeof error.data === "string") detail = error.data; } else if (error instanceof Error) { detail = error.message; } diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index ded007b..7f494ba 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -1,19 +1,25 @@ -import { Outlet, useNavigation } from "react-router-dom"; +import { Suspense } from "react"; +import { Outlet, useLocation, useNavigation } from "react-router-dom"; +import { useIsFetching } from "@tanstack/react-query"; import Header from "./Header"; import MobileBackButton from "./MobileBackButton"; import Footer from "./Footer"; +import ErrorBoundary from "./ErrorBoundary"; import LoginModal from "../auth/LoginModal"; import { LoginModalProvider } from "../../lib/loginModal"; -import { useRevalidateOnFocus } from "../../hooks/useRevalidateOnFocus"; export default function Layout() { - const isLoading = useNavigation().state === "loading"; - useRevalidateOnFocus(); + const routeLoading = useNavigation().state === "loading"; + const queriesLoading = useIsFetching() > 0; + const showProgress = routeLoading || queriesLoading; + // Remount ErrorBoundary + Suspense on navigation so a fresh page doesn't + // inherit the previous page's error or fallback state. + const routeKey = useLocation().pathname; return (
- {isLoading && ( + {showProgress && (
- + + + + +
diff --git a/web/src/components/nav/ThreadLink.tsx b/web/src/components/nav/ThreadLink.tsx index 5e562d4..de42445 100644 --- a/web/src/components/nav/ThreadLink.tsx +++ b/web/src/components/nav/ThreadLink.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router-dom"; import Avatar from "../Avatar"; -import type { Participant } from "../../router/loaders/board"; +import type { Participant } from "../../lib/boardThreads"; const COL_POSTERS = "w-20"; const COL_REPLIES = "w-14"; diff --git a/web/src/components/post/ModerationBadge.tsx b/web/src/components/post/ModerationBadge.tsx new file mode 100644 index 0000000..5bc32ba --- /dev/null +++ b/web/src/components/post/ModerationBadge.tsx @@ -0,0 +1,22 @@ +import { Ban, EyeOff } from "lucide-react"; + +interface ModerationBadgeProps { + isHidden: boolean; + isBannedAuthor: boolean; +} + +export default function ModerationBadge({ + isHidden, + isBannedAuthor, +}: ModerationBadgeProps) { + if (!isHidden && !isBannedAuthor) return null; + return ( + + {isHidden ? : } + {isHidden ? "hidden" : "author banned"} + + ); +} diff --git a/web/src/components/post/PostActions.tsx b/web/src/components/post/PostActions.tsx index fc673e9..ffd1f44 100644 --- a/web/src/components/post/PostActions.tsx +++ b/web/src/components/post/PostActions.tsx @@ -1,21 +1,29 @@ import { useRef, useState, useEffect } from "react"; -import { Reply, MoreHorizontal, Trash2, Ban, EyeOff } from "lucide-react"; +import { Reply, MoreHorizontal, Trash2, Ban, EyeOff, Eye } from "lucide-react"; interface PostActionsProps { isAuthor: boolean; isSysop: boolean; + banRkey?: string | null; + hideRkey?: string | null; onDelete?: () => void; onBan?: () => void; + onUnban?: (rkey: string) => void; onHide?: () => void; + onUnhide?: (rkey: string) => void; onReplyTo?: () => void; } export default function PostActions({ isAuthor, isSysop, + banRkey, + hideRkey, onDelete, onBan, + onUnban, onHide, + onUnhide, onReplyTo, }: PostActionsProps) { const [open, setOpen] = useState(false); @@ -33,9 +41,11 @@ export default function PostActions({ }, [open]); const canDelete = isAuthor && !!onDelete; - const canBan = isSysop && !isAuthor && !!onBan; - const canHide = isSysop && !!onHide; - const hasModActions = canDelete || canBan || canHide; + const canBan = isSysop && !isAuthor && !!onBan && !banRkey; + const canUnban = isSysop && !!onUnban && !!banRkey; + const canHide = isSysop && !!onHide && !hideRkey; + const canUnhide = isSysop && !!onUnhide && !!hideRkey; + const hasModActions = canDelete || canBan || canUnban || canHide || canUnhide; if (!onReplyTo && !hasModActions) return null; @@ -79,11 +89,27 @@ export default function PostActions({ ban )} + {canUnban && ( + + )} {canHide && ( )} + {canUnhide && ( + + )}
)}
diff --git a/web/src/components/post/ReplyCard.tsx b/web/src/components/post/ReplyCard.tsx index 311e271..9150283 100644 --- a/web/src/components/post/ReplyCard.tsx +++ b/web/src/components/post/ReplyCard.tsx @@ -1,4 +1,6 @@ +import { truncate } from "../../lib/util"; import AttachmentLink from "./AttachmentLink"; +import ModerationBadge from "./ModerationBadge"; import PostActions from "./PostActions"; import PostBody from "./PostBody"; import PostMeta from "./PostMeta"; @@ -20,11 +22,15 @@ interface ReplyCardProps { userDid: string; sysopDid: string; parentPost?: Reply; + banRkey?: string | null; + hideRkey?: string | null; onReplyTo: () => void; onParentClick?: () => void; onDelete: () => void; onBan: () => void; + onUnban: (rkey: string) => void; onHide: () => void; + onUnhide: (rkey: string) => void; } export default function ReplyCard({ @@ -32,32 +38,47 @@ export default function ReplyCard({ userDid, sysopDid, parentPost, + banRkey, + hideRkey, onReplyTo, onParentClick, onDelete, onBan, + onUnban, onHide, + onUnhide, }: ReplyCardProps) { const isAuthor = userDid === reply.did; const isSysop = userDid === sysopDid; + const isModerated = !!banRkey || !!hideRkey; return (
+ + {parentPost && ( )} diff --git a/web/src/components/post/ThreadCard.tsx b/web/src/components/post/ThreadCard.tsx index a45d5cf..5e6985f 100644 --- a/web/src/components/post/ThreadCard.tsx +++ b/web/src/components/post/ThreadCard.tsx @@ -1,41 +1,60 @@ -import type { ThreadObj } from "../../router/loaders"; +import type { ThreadRoot } from "../../lib/thread"; import AttachmentLink from "./AttachmentLink"; +import ModerationBadge from "./ModerationBadge"; import PostActions from "./PostActions"; import PostBody from "./PostBody"; import PostMeta from "./PostMeta"; -interface ThreadHeaderProps { - thread: ThreadObj; +interface ThreadCardProps { + thread: ThreadRoot; userDid?: string; sysopDid: string; + banRkey?: string | null; + hideRkey?: string | null; onDelete: () => void; onBan: () => void; + onUnban: (rkey: string) => void; onHide: () => void; + onUnhide: (rkey: string) => void; } export default function ThreadCard({ thread, userDid, sysopDid, + banRkey, + hideRkey, onDelete, onBan, + onUnban, onHide, -}: ThreadHeaderProps) { + onUnhide, +}: ThreadCardProps) { const isAuthor = !!(userDid && userDid === thread.did); const isSysop = !!(userDid && userDid === sysopDid); + const isModerated = !!banRkey || !!hideRkey; return ( -
+
+

{thread.title}

diff --git a/web/src/hooks/useDiscovery.ts b/web/src/hooks/useDiscovery.ts deleted file mode 100644 index 292f4a0..0000000 --- a/web/src/hooks/useDiscovery.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** Fetch discovered BBSes from the Lightrail API, cached in memory. */ - -import { useEffect, useState } from "react"; -import { TTLCache } from "../lib/cache"; -import { getAvatars, getRecord, resolveIdentitiesBatch } from "../lib/atproto"; -import { SITE } from "../lib/lexicon"; -import { SERVICES } from "../lib/shared"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; -import type { XyzAtbbsSite } from "../lexicons"; - -interface LightrailRepo { - did: string; -} - -export interface DiscoveredBBS { - did: string; - handle: string; - name: string; - description: string; - avatar?: string; -} - -const discoveryCache = new TTLCache(5 * 60 * 1000); - -export function useDiscovery(): DiscoveredBBS[] { - const [discovered, setDiscovered] = useState([]); - - useEffect(() => { - const cached = discoveryCache.get("all"); - if (cached) { - setDiscovered(cached); - return; - } - (async () => { - try { - const response = await fetch( - `${SERVICES.lightrail}/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`, - ); - const data = (await response.json()) as { repos: LightrailRepo[] }; - if (!data.repos.length) return; - - const shuffled = data.repos.sort(() => Math.random() - 0.5); - const identities = await resolveIdentitiesBatch( - shuffled.map((repo) => repo.did), - ); - - const items: DiscoveredBBS[] = []; - for (const repo of shuffled) { - if (!(repo.did in identities)) continue; - try { - const siteRecord = await getRecord(repo.did, SITE, "self"); - if (!is(siteSchema, siteRecord.value)) continue; - const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; - items.push({ - did: repo.did, - handle: identities[repo.did].handle, - name: siteValue.name || identities[repo.did].handle, - description: siteValue.description || "", - }); - } catch { - continue; - } - } - - const avatars = await getAvatars(items.map((item) => item.did)); - for (const item of items) { - item.avatar = avatars[item.did]; - } - - discoveryCache.set("all", items); - setDiscovered(items); - } catch {} - })(); - }, []); - - return discovered; -} diff --git a/web/src/hooks/useRevalidateOnFocus.ts b/web/src/hooks/useRevalidateOnFocus.ts deleted file mode 100644 index f7fcf27..0000000 --- a/web/src/hooks/useRevalidateOnFocus.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** Revalidate active route loaders when the tab regains focus/visibility or - * the browser reconnects. Throttled so rapid focus changes don't storm. */ - -import { useEffect, useRef } from "react"; -import { useRevalidator } from "react-router-dom"; - -const MIN_INTERVAL_MS = 30_000; - -export function useRevalidateOnFocus() { - const revalidator = useRevalidator(); - const lastAt = useRef(0); - - useEffect(() => { - function maybeRevalidate() { - if (document.hidden) return; - if (revalidator.state !== "idle") return; - const now = Date.now(); - if (now - lastAt.current < MIN_INTERVAL_MS) return; - lastAt.current = now; - revalidator.revalidate(); - } - - window.addEventListener("focus", maybeRevalidate); - window.addEventListener("online", maybeRevalidate); - document.addEventListener("visibilitychange", maybeRevalidate); - return () => { - window.removeEventListener("focus", maybeRevalidate); - window.removeEventListener("online", maybeRevalidate); - document.removeEventListener("visibilitychange", maybeRevalidate); - }; - }, [revalidator]); -} diff --git a/web/src/hooks/useThreadReplies.ts b/web/src/hooks/useThreadReplies.ts index 42d2b57..41ed006 100644 --- a/web/src/hooks/useThreadReplies.ts +++ b/web/src/hooks/useThreadReplies.ts @@ -1,298 +1,92 @@ -/** Manages pagination, record fetching, and optimistic updates for a - * thread's reply list. */ +/** Thread-page data fetcher: refs from Constellation, hydrated page replies, + * plus pagination + scroll-to-reply helpers. Optimistic mutations are in + * Thread.tsx and update the same query caches via setQueryData. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect } from "react"; import { useSearchParams } from "react-router-dom"; -import { getRecordsBatch, resolveIdentitiesBatch } from "../lib/atproto"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { threadPageQuery, threadRefsQuery } from "../lib/queries"; import { parseAtUri } from "../lib/util"; -import type { BBS } from "../lib/bbs"; -import type { Reply } from "../components/post/ReplyCard"; import { REPLIES_PER_PAGE, - type BacklinkRef, - refToUri, + clampPage, + pageForRkey, pageForReply, rkeyFromHash, - pageForRkey, - clampPage, - recordToReply, } from "../lib/replies"; -interface ThreadLoaderData { - bbs: BBS; - allRefs: BacklinkRef[]; -} - -export function useThreadReplies(loaded: ThreadLoaderData) { - const { bbs, allRefs } = loaded; +export function useThreadReplies(threadUri: string) { const [params, setParams] = useSearchParams(); - // --- Optimistic state --- - // - // PDS writes land instantly but Constellation lags behind; track - // in-flight mutations here so the UI stays responsive. - - const [pendingAdds, setPendingAdds] = useState< - Record - >({}); - const [pendingDeletes, setPendingDeletes] = useState>(new Set()); - - // Combine the loader's refs with our local overlay. The prune effect - // below keeps pendingAdds from growing stale. - const loaderFingerprint = allRefs.map((r) => r.rkey).join("|"); - - const refs = useMemo(() => { - const base = allRefs.filter((r) => !pendingDeletes.has(refToUri(r))); - const adds = Object.values(pendingAdds).map((p) => p.ref); - return [...base, ...adds]; - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on - // loaderFingerprint (string) rather than allRefs (unstable reference) - }, [loaderFingerprint, pendingAdds, pendingDeletes]); - - // When the loader refreshes and allRefs now includes a reply we added - // optimistically, drop it from pendingAdds so the loader is the source - // of truth going forward. - useEffect(() => { - setPendingAdds((prev) => { - const loaderUris = new Set(allRefs.map(refToUri)); - let changed = false; - const next: typeof prev = {}; - for (const [uri, entry] of Object.entries(prev)) { - if (loaderUris.has(uri)) { - changed = true; - } else { - next[uri] = entry; - } - } - return changed ? next : prev; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- same reason - }, [loaderFingerprint]); - + const { data: refs } = useSuspenseQuery(threadRefsQuery(threadUri)); const totalPages = Math.max(1, Math.ceil(refs.length / REPLIES_PER_PAGE)); - // --- Pagination --- - - // Determine initial scroll target from ?reply= or #reply- - const initialReplyParam = params.get("reply"); - const initialHashRkey = rkeyFromHash(); - const initialScrollRkey = initialReplyParam - ? parseAtUri(initialReplyParam).rkey - : initialHashRkey; - - const [page, setPage] = useState(() => { - const fromUrl = parseInt(params.get("page") ?? "1", 10); - const fromReply = pageForReply(allRefs, initialReplyParam); - const fromHash = pageForRkey(allRefs, initialHashRkey); - return clampPage(fromHash ?? fromReply ?? fromUrl, allRefs.length); - }); - - const [initialScrollDone, setInitialScrollDone] = - useState(!initialScrollRkey); - - // Keep the URL in sync when the user changes page (e.g. via PageNav). - useEffect(() => { - const urlPage = parseInt(params.get("page") ?? "1", 10); - if (urlPage === page) return; - setParams((prev) => { - const next = new URLSearchParams(prev); - if (page === 1) next.delete("page"); - else next.set("page", String(page)); - return next; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- only when - // `page` changes, not when params object identity changes - }, [page]); - - // Keep the page in sync when the user hits Back/Forward. - const urlPage = parseInt(params.get("page") ?? "1", 10); - useEffect(() => { - if (urlPage !== page) setPage(urlPage); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlPage]); - - // --- Hydration --- - - const [replies, setReplies] = useState([]); - const [loading, setLoading] = useState(true); - - // All replies we've ever seen — accumulates across page changes so parent - // previews and scroll targets always resolve, even for off-page replies. - const [replyCache, setReplyCache] = useState>({}); - - // Pending scroll target — set when navigating to a reply on another page. - // Cleared once the scroll completes. - const [pendingScrollRkey, setPendingScrollRkey] = useState( - null, - ); - - const fetchVisiblePage = useCallback( - async (currentRefs: BacklinkRef[], currentPage: number) => { - setLoading(true); - - const start = (currentPage - 1) * REPLIES_PER_PAGE; - const slice = currentRefs.slice(start, start + REPLIES_PER_PAGE); - - if (!slice.length) { - setReplies([]); - setLoading(false); - return; - } - - // Fetch records from Slingshot. - const records = await getRecordsBatch(slice); - - const visible = records; - - // Resolve author handles and build Reply objects. - const dids = visible.map((r) => parseAtUri(r.uri).did); - const authors = await resolveIdentitiesBatch(dids); - const items: Reply[] = visible - .map((r) => recordToReply(r, authors)) - .filter((r): r is Reply => r !== null); - - // Merge in optimistic adds that Slingshot hasn't caught up to yet. - const fetchedUris = new Set(items.map((i) => i.uri)); - const sliceUris = new Set(slice.map(refToUri)); - for (const [uri, pending] of Object.entries(pendingAdds)) { - if (!fetchedUris.has(uri) && sliceUris.has(uri)) { - items.push(pending.item); - } - } + // --- Page derived from URL, clamped to the available range --- - // Drop just-deleted replies; Constellation and Slingshot can lag - // behind the PDS and briefly return stale copies. - const visibleItems = items.filter( - (item) => !pendingDeletes.has(item.uri), - ); + const requestedPage = parseInt(params.get("page") ?? "1", 10); + const replyParam = params.get("reply"); + const hashRkey = rkeyFromHash(); + const initialPage = + pageForRkey(refs, hashRkey) ?? + pageForReply(refs, replyParam) ?? + requestedPage; + const page = clampPage(initialPage, refs.length); - visibleItems.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); - setReplies(visibleItems); - setLoading(false); + const pageStart = (page - 1) * REPLIES_PER_PAGE; + const pageRefs = refs.slice(pageStart, pageStart + REPLIES_PER_PAGE); - // Add current page replies to the cache - const newCache: Record = {}; - for (const item of visibleItems) newCache[item.uri] = item; - - // Fetch any parent replies not already known - const missingParents = visibleItems - .filter((item) => item.parent && !newCache[item.parent!]) - .map((item) => item.parent!) - .filter((uri) => !replyCache[uri]); - if (missingParents.length) { - const parentRefs = [...new Set(missingParents)].map((uri) => - parseAtUri(uri), - ); - const parentRecords = await getRecordsBatch(parentRefs); - const parentDids = parentRecords.map( - (record) => parseAtUri(record.uri).did, - ); - const parentAuthors = await resolveIdentitiesBatch(parentDids); - for (const record of parentRecords) { - const reply = recordToReply(record, parentAuthors); - if (reply) newCache[reply.uri] = reply; - } - } - - setReplyCache((prev) => ({ ...prev, ...newCache })); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- pendingAdds - // and pendingDeletes are included so the merge/filter steps always see - // the latest optimistic set - [bbs, pendingAdds, pendingDeletes], + const { data: pageData } = useSuspenseQuery( + threadPageQuery(threadUri, page, pageRefs), ); + const { replies, parentReplies } = pageData; - // Re-fetch whenever the visible page or the underlying ref list changes. - const refsLength = refs.length; - useEffect(() => { - fetchVisiblePage(refs, page); - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on - // stable scalars, not the refs array reference or callback identity - }, [refsLength, page, loaderFingerprint]); + // --- Keep URL in sync when the derived page differs from what's in it --- - // Scroll to a reply after a cross-page navigation completes. useEffect(() => { - if (!pendingScrollRkey) return; - const id = `reply-${pendingScrollRkey}`; - const el = document.getElementById(id); - if (el) { - el.scrollIntoView({ behavior: "smooth" }); - setPendingScrollRkey(null); - } - }, [pendingScrollRkey, replies]); - - // Scroll to the initial target after the first load. - useEffect(() => { - if (initialScrollDone || loading || !initialScrollRkey) return; - setInitialScrollDone(true); - const el = document.getElementById(`reply-${initialScrollRkey}`); - if (el) { - el.scrollIntoView({ behavior: "instant" }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, replies]); - - // --- Public actions --- + const fromUrl = parseInt(params.get("page") ?? "1", 10); + if (fromUrl === page) return; + setParams((prev) => writePageParam(prev, page), { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- params identity churns + }, [page]); - const addOptimisticReply = useCallback( - (item: Reply) => { - const ref = parseAtUri(item.uri); - setPendingAdds((prev) => ({ ...prev, [item.uri]: { ref, item } })); + // --- Navigation helpers --- - const newTotalPages = Math.max( - 1, - Math.ceil((refs.length + 1) / REPLIES_PER_PAGE), - ); - if (page === newTotalPages) { - // Already on the last page — just append. - setReplies((prev) => - [...prev, item].sort((a, b) => - a.createdAt.localeCompare(b.createdAt), - ), - ); - } else { - // Jump to the (new) last page so the reply is visible. - setPage(newTotalPages); - } - }, - [refs.length, page], - ); + function setPage(next: number) { + const clamped = clampPage(next, refs.length); + setParams((prev) => writePageParam(prev, clamped)); + } - const removeReply = useCallback((uri: string) => { - setPendingDeletes((prev) => new Set(prev).add(uri)); - setReplies((prev) => prev.filter((r) => r.uri !== uri)); - }, []); - - const scrollToReply = useCallback( - (uri: string) => { - const { rkey } = parseAtUri(uri); - // If already on screen, just scroll - const el = document.getElementById(`reply-${rkey}`); - if (el) { - el.scrollIntoView({ behavior: "smooth" }); - return; - } - // Find the page and navigate — the effect will scroll once loaded - const idx = refs.findIndex((r) => refToUri(r) === uri); - if (idx >= 0) { - const targetPage = Math.floor(idx / REPLIES_PER_PAGE) + 1; - setPendingScrollRkey(rkey); - setPage(targetPage); - } - }, - [refs], - ); + function scrollToReply(uri: string) { + const { rkey } = parseAtUri(uri); + const onScreen = document.getElementById(`reply-${rkey}`); + if (onScreen) { + onScreen.scrollIntoView({ behavior: "smooth" }); + return; + } + const targetPage = pageForRkey(refs, rkey); + if (targetPage === null) return; + setParams((prev) => { + const next = writePageParam(prev, targetPage); + next.set("reply", uri); + return next; + }); + } return { page, setPage, totalPages, - replies, - loading, refs, - replyCache, + replies, + parentReplies, scrollToReply, - addOptimisticReply, - removeReply, }; } + +function writePageParam(prev: URLSearchParams, page: number) { + const next = new URLSearchParams(prev); + if (page === 1) next.delete("page"); + else next.set("page", String(page)); + next.delete("reply"); + return next; +} diff --git a/web/src/lib/atproto.ts b/web/src/lib/atproto.ts index 766928c..6880edc 100644 --- a/web/src/lib/atproto.ts +++ b/web/src/lib/atproto.ts @@ -1,12 +1,17 @@ /** Read-side wrappers for Slingshot and Constellation (no auth needed). */ -import { TTLCache } from "./cache"; +import { queryClient, STALE_SLOW } from "./queryClient"; import { SERVICES } from "./shared"; import { parseAtUri } from "./util"; const SLINGSHOT = SERVICES.slingshot; const CONSTELLATION = SERVICES.constellation; +const BSKY_CDN = "https://cdn.bsky.app"; +const BSKY_PROFILE = "app.bsky.actor.profile"; + +// --- Types --- + export interface MiniDoc { did: string; handle: string; @@ -36,75 +41,24 @@ interface ListRecordsResponse { cursor?: string; } +// --- Low-level JSON fetcher --- + async function fetchJson(url: string): Promise { const resp = await fetch(url); if (!resp.ok) throw new Error(`${resp.status} ${url}`); return resp.json() as Promise; } -const identityCache = new TTLCache(5 * 60 * 1000); -// `null` means we've looked and there's no avatar — cache that too so we don't refetch. -const avatarCache = new TTLCache(5 * 60 * 1000); -const backlinkCountCache = new TTLCache(60 * 1000); - -const BSKY_CDN = "https://cdn.bsky.app"; -const BSKY_PROFILE = "app.bsky.actor.profile"; - -function extractAvatarCid(value: Record): string | null { - const avatar = value.avatar as { ref?: { $link?: string } } | undefined; - return avatar?.ref?.$link ?? null; -} - -export async function getAvatar(did: string): Promise { - const cached = avatarCache.get(did); - if (cached !== undefined) return cached ?? undefined; - try { - const record = await getRecord(did, BSKY_PROFILE, "self"); - const cid = extractAvatarCid(record.value); - const url = cid ? `${BSKY_CDN}/img/avatar/plain/${did}/${cid}` : null; - avatarCache.set(did, url); - return url ?? undefined; - } catch { - avatarCache.set(did, null); - return undefined; - } -} +// --- Records --- -export async function getAvatars( - dids: string[], -): Promise> { - const unique = [...new Set(dids)]; - const urls = await Promise.all(unique.map(getAvatar)); - const map: Record = {}; - unique.forEach((did, index) => { - const url = urls[index]; - if (url) map[did] = url; - }); - return map; -} - -export async function resolveIdentity(identifier: string): Promise { - const cached = identityCache.get(identifier); - if (cached) return cached; - - const doc = await fetchJson( - `${SLINGSHOT}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, +async function fetchRecord( + did: string, + collection: string, + rkey: string, +): Promise { + return fetchJson( + `${SLINGSHOT}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, ); - identityCache.set(identifier, doc); - identityCache.set(doc.did, doc); - return doc; -} - -export async function resolveIdentitiesBatch( - dids: string[], -): Promise> { - const unique = [...new Set(dids)]; - const results = await Promise.allSettled(unique.map(resolveIdentity)); - const map: Record = {}; - for (const result of results) { - if (result.status === "fulfilled") map[result.value.did] = result.value; - } - return map; } export async function getRecord( @@ -112,9 +66,11 @@ export async function getRecord( collection: string, rkey: string, ): Promise { - return fetchJson( - `${SLINGSHOT}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, - ); + return queryClient.ensureQueryData({ + queryKey: ["record", did, collection, rkey], + queryFn: () => fetchRecord(did, collection, rkey), + staleTime: STALE_SLOW, + }); } export async function getRecordByUri(uri: string): Promise { @@ -123,9 +79,7 @@ export async function getRecordByUri(uri: string): Promise { } export async function getRecordsByUri(uris: string[]): Promise { - const results = await Promise.allSettled( - uris.map((uri) => getRecordByUri(uri)), - ); + const results = await Promise.allSettled(uris.map(getRecordByUri)); return results .filter( (result): result is PromiseFulfilledResult => @@ -148,6 +102,103 @@ export async function getRecordsBatch( .map((result) => result.value); } +export async function listRecords( + pdsUrl: string, + did: string, + collection: string, + limit = 100, +): Promise<{ uri: string; cid: string; value: Record }[]> { + const all: ListRecordsResponse["records"] = []; + let cursor: string | undefined; + while (true) { + let url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=${limit}`; + if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; + try { + const data = await fetchJson(url); + all.push(...data.records); + if (!data.cursor) break; + cursor = data.cursor; + } catch { + break; + } + } + return all; +} + +// --- Identity (DID doc) --- + +export async function fetchIdentityDoc(identifier: string): Promise { + return fetchJson( + `${SLINGSHOT}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, + ); +} + +export async function resolveIdentity(identifier: string): Promise { + const doc = await queryClient.ensureQueryData({ + queryKey: ["identity", identifier], + queryFn: () => fetchIdentityDoc(identifier), + staleTime: STALE_SLOW, + }); + // Seed the DID-keyed entry too, so later DID lookups hit cache. + if (doc.did !== identifier) { + queryClient.setQueryData(["identity", doc.did], doc); + } + return doc; +} + +export async function resolveIdentitiesBatch( + ids: string[], +): Promise> { + const unique = [...new Set(ids)]; + const results = await Promise.allSettled(unique.map(resolveIdentity)); + const map: Record = {}; + for (const result of results) { + if (result.status === "fulfilled") map[result.value.did] = result.value; + } + return map; +} + +// --- Avatar --- + +function extractAvatarCid(value: Record): string | null { + const avatar = value.avatar as { ref?: { $link?: string } } | undefined; + return avatar?.ref?.$link ?? null; +} + +export async function fetchAvatarUrl(did: string): Promise { + try { + const record = await getRecord(did, BSKY_PROFILE, "self"); + const cid = extractAvatarCid(record.value); + return cid ? `${BSKY_CDN}/img/avatar/plain/${did}/${cid}` : null; + } catch { + return null; + } +} + +export async function getAvatar(did: string): Promise { + const url = await queryClient.ensureQueryData({ + queryKey: ["avatar", did], + queryFn: () => fetchAvatarUrl(did), + staleTime: STALE_SLOW, + }); + return url ?? undefined; +} + +export async function getAvatars( + dids: string[], +): Promise> { + const unique = [...new Set(dids)]; + const urls = await Promise.all(unique.map(getAvatar)); + const map: Record = {}; + unique.forEach((did, index) => { + const url = urls[index]; + if (url) map[did] = url; + }); + return map; +} + +// --- Backlinks (Constellation) --- + export async function getBacklinks( subject: string, source: string, @@ -159,22 +210,28 @@ export async function getBacklinks( return fetchJson(url); } -export async function getBacklinkCount( +export async function fetchBacklinkCount( subject: string, source: string, ): Promise { - const key = `${source}\t${subject}`; - const cached = backlinkCountCache.get(key); - if (cached !== undefined) return cached; try { const { total } = await getBacklinks(subject, source, 1); - backlinkCountCache.set(key, total); return total; } catch { return 0; } } +export async function getBacklinkCount( + subject: string, + source: string, +): Promise { + return queryClient.ensureQueryData({ + queryKey: ["backlink-count", source, subject], + queryFn: () => fetchBacklinkCount(subject, source), + }); +} + export async function getBacklinkCountsBatch( subjects: string[], source: string, @@ -190,6 +247,8 @@ export async function getBacklinkCountsBatch( return map; } +// --- Fetch-and-hydrate (backlinks -> records -> identities) --- + interface HydratedRecord { uri: string; did: string; @@ -248,26 +307,3 @@ export async function fetchAndHydrate( return { records: hydrated, cursor: backlinks.cursor ?? null }; } - -export async function listRecords( - pdsUrl: string, - did: string, - collection: string, - limit = 100, -): Promise<{ uri: string; cid: string; value: Record }[]> { - const all: ListRecordsResponse["records"] = []; - let cursor: string | undefined; - while (true) { - let url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=${limit}`; - if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; - try { - const data = await fetchJson(url); - all.push(...data.records); - if (!data.cursor) break; - cursor = data.cursor; - } catch { - break; - } - } - return all; -} diff --git a/web/src/lib/bbs.ts b/web/src/lib/bbs.ts index 8d64227..22f4753 100644 --- a/web/src/lib/bbs.ts +++ b/web/src/lib/bbs.ts @@ -1,25 +1,21 @@ /** Resolve a handle to a fully hydrated BBS via Slingshot/Constellation. */ -import { TTLCache } from "./cache"; import { getRecord, - getRecordsBatch, - getBacklinks, resolveIdentity, type MiniDoc, type ATRecord, } from "./atproto"; -import { SITE, BOARD, POST, BAN, HIDE } from "./lexicon"; -import { makeAtUri, parseAtUri } from "./util"; +import { queryClient } from "./queryClient"; +import { SITE } from "./lexicon"; +import { parseAtUri } from "./util"; import { is } from "@atcute/lexicons/validations"; import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; -import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsSite, XyzAtbbsBoard, XyzAtbbsPost } from "../lexicons"; +import type { XyzAtbbsSite, XyzAtbbsBoard } from "../lexicons"; export class BBSNotFoundError extends Error {} export class NoBBSError extends Error {} -export class NetworkError extends Error {} export interface Board { slug: string; @@ -55,28 +51,19 @@ export interface Site { export interface BBS { identity: MiniDoc; site: Site; - news: NewsPost[]; } -const bbsCache = new TTLCache(5 * 60 * 1000); - -export function invalidateBBSCache() { - bbsCache.clear(); +export function invalidateAllBBSCaches() { + queryClient.invalidateQueries({ queryKey: ["bbs"] }); + queryClient.invalidateQueries({ queryKey: ["bbs-moderation"] }); + queryClient.invalidateQueries({ queryKey: ["sysop-moderation"] }); } export async function resolveBBS(handle: string): Promise { - const cached = bbsCache.get(handle); - if (cached) return cached; - const bbs = await _resolveBBS(handle); - bbsCache.set(handle, bbs); - return bbs; -} - -async function _resolveBBS(handle: string): Promise { let identity: MiniDoc; try { identity = await resolveIdentity(handle); - } catch (e) { + } catch { throw new BBSNotFoundError(`Could not resolve handle: ${handle}`); } if (!identity.pds) { @@ -94,18 +81,14 @@ async function _resolveBBS(handle: string): Promise { throw new NoBBSError(`${handle} has an invalid site record.`); } const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; - const siteUri = makeAtUri(identity.did, SITE, "self"); const boardUris: string[] = siteValue.boards ?? []; - const [boardResults, newsBacklinks] = await Promise.all([ - Promise.allSettled( - boardUris.map((uri) => { - const parsed = parseAtUri(uri); - return getRecord(parsed.did, parsed.collection, parsed.rkey); - }), - ), - getBacklinks(siteUri, `${POST}:scope`, 50).catch(() => null), - ]); + const boardResults = await Promise.allSettled( + boardUris.map((uri) => { + const parsed = parseAtUri(uri); + return getRecord(parsed.did, parsed.collection, parsed.rkey); + }), + ); const boards: Board[] = []; boardResults.forEach((result, index) => { @@ -122,33 +105,6 @@ async function _resolveBBS(handle: string): Promise { }); }); - // News - posts scoped to the site, only sysop's repo - let news: NewsPost[] = []; - if (newsBacklinks) { - const sysopRefs = newsBacklinks.records.filter( - (ref) => ref.did === identity.did, - ); - const newsRecords = await getRecordsBatch(sysopRefs); - news = newsRecords - .filter((record) => is(postSchema, record.value)) - .filter((record) => { - const value = record.value as unknown as XyzAtbbsPost.Main; - return value.title && !value.root; // root posts with titles are news/threads - }) - .map((record) => { - const value = record.value as unknown as XyzAtbbsPost.Main; - return { - uri: record.uri, - rkey: parseAtUri(record.uri).rkey, - title: value.title ?? "", - body: value.body, - createdAt: value.createdAt, - attachments: value.attachments as PostAttachment[] | undefined, - }; - }); - news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - } - return { identity, site: { @@ -159,6 +115,5 @@ async function _resolveBBS(handle: string): Promise { createdAt: siteValue.createdAt ?? "", updatedAt: siteValue.updatedAt, }, - news, }; } diff --git a/web/src/lib/bbsModeration.ts b/web/src/lib/bbsModeration.ts new file mode 100644 index 0000000..95e5142 --- /dev/null +++ b/web/src/lib/bbsModeration.ts @@ -0,0 +1,49 @@ +/** Lookup tables for a BBS's moderation state: who is banned, which posts + * are hidden, and the rkeys of those records (so the sysop can undo). */ + +import { listRecords } from "./atproto"; +import { BAN, HIDE } from "./lexicon"; +import { parseAtUri } from "./util"; +import { is } from "@atcute/lexicons/validations"; +import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; +import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; +import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons"; + +export interface BBSModeration { + bannedDids: Set; + hiddenUris: Set; + /** DID → rkey of that user's ban record on the sysop's PDS. */ + banRkeys: Record; + /** Post URI → rkey of its hide record on the sysop's PDS. */ + hideRkeys: Record; +} + +export async function fetchBBSModeration( + pdsUrl: string, + did: string, +): Promise { + const [banRecs, hideRecs] = await Promise.all([ + listRecords(pdsUrl, did, BAN).catch(() => []), + listRecords(pdsUrl, did, HIDE).catch(() => []), + ]); + + const bannedDids = new Set(); + const banRkeys: Record = {}; + for (const record of banRecs) { + if (!is(banSchema, record.value)) continue; + const value = record.value as unknown as XyzAtbbsBan.Main; + bannedDids.add(value.did); + banRkeys[value.did] = parseAtUri(record.uri).rkey; + } + + const hiddenUris = new Set(); + const hideRkeys: Record = {}; + for (const record of hideRecs) { + if (!is(hideSchema, record.value)) continue; + const value = record.value as unknown as XyzAtbbsHide.Main; + hiddenUris.add(value.uri); + hideRkeys[value.uri] = parseAtUri(record.uri).rkey; + } + + return { bannedDids, hiddenUris, banRkeys, hideRkeys }; +} diff --git a/web/src/router/loaders/board.ts b/web/src/lib/boardThreads.ts similarity index 67% rename from web/src/router/loaders/board.ts rename to web/src/lib/boardThreads.ts index cc61d42..1ff9e99 100644 --- a/web/src/router/loaders/board.ts +++ b/web/src/lib/boardThreads.ts @@ -1,5 +1,10 @@ -import type { LoaderFunctionArgs } from "react-router-dom"; -import { resolveBBS, type BBS } from "../../lib/bbs"; +/** Build a page of thread summaries for a board, sorted by last activity. + * + * Scans recent board activity (threads + replies) from Constellation and + * collects unique thread URIs in the order they appear. Since Constellation + * returns newest posts first, the first time a thread URI appears is its + * most recent activity — giving us bump order naturally. */ + import { getAvatars, getBacklinkCountsBatch, @@ -7,12 +12,12 @@ import { getRecordsBatch, getRecordsByUri, resolveIdentitiesBatch, -} from "../../lib/atproto"; -import { POST, BOARD } from "../../lib/lexicon"; -import { makeAtUri, parseAtUri } from "../../lib/util"; +} from "./atproto"; +import { POST, BOARD } from "./lexicon"; +import { makeAtUri, parseAtUri } from "./util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } from "../../lexicons"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../lexicons"; export interface Participant { did: string; @@ -33,27 +38,21 @@ export interface ThreadItem { participants: Participant[]; } +export interface ThreadPageResult { + threads: ThreadItem[]; + cursor: string | null; +} + const MAX_SCANS = 4; const PAGE_SIZE = 25; -/** - * Fetch threads for a board, sorted by last activity (bump order). - * - * Scans recent board activity (threads + replies) and collects unique - * thread URIs in the order they appear. Since Constellation returns - * newest posts first, the first time a thread URI appears is its most - * recent activity — giving us bump order naturally. - */ export async function hydrateThreadPage( - bbs: BBS, + bbsDid: string, slug: string, cursor?: string, -): Promise<{ threads: ThreadItem[]; cursor: string | null }> { - const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); +): Promise { + const boardUri = makeAtUri(bbsDid, BOARD, slug); - // Phase 1: Scan board activity to find unique thread URIs and their posters. - // Constellation returns newest-first, so first-seen activity per thread = most - // recent, and Set insertion order preserves newest-first poster order. const lastActivity = new Map(); const postersByThread = new Map>(); let scanCursor = cursor; @@ -89,7 +88,6 @@ export async function hydrateThreadPage( if (!scanCursor) break; } - // Phase 2: Fetch root post records for the thread URIs. const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE); const rootRecords = await getRecordsByUri(threadUris); @@ -99,8 +97,6 @@ export async function hydrateThreadPage( return value.title && !value.root; }); - // Phase 3: Resolve identities+avatars for every poster across all threads, - // count replies, and build ThreadItems. const allDids = new Set(); for (const record of validRoots) { allDids.add(parseAtUri(record.uri).did); @@ -147,14 +143,3 @@ export async function hydrateThreadPage( return { threads, cursor: scanCursor ?? 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/lib/cache.ts b/web/src/lib/cache.ts deleted file mode 100644 index fd522f6..0000000 --- a/web/src/lib/cache.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Simple in-memory cache with TTL. */ - -export class TTLCache { - private entries = new Map(); - - constructor(private ttl: number) {} - - get(key: K): V | undefined { - const entry = this.entries.get(key); - if (!entry) return undefined; - if (entry.expires < Date.now()) { - this.entries.delete(key); - return undefined; - } - return entry.value; - } - - set(key: K, value: V): void { - this.entries.set(key, { value, expires: Date.now() + this.ttl }); - } - - clear(): void { - this.entries.clear(); - } -} diff --git a/web/src/lib/discovery.ts b/web/src/lib/discovery.ts new file mode 100644 index 0000000..8404cb9 --- /dev/null +++ b/web/src/lib/discovery.ts @@ -0,0 +1,64 @@ +/** Fetch a random list of BBSes from the Lightrail API, with avatars. */ + +import { getAvatars, getRecord, resolveIdentitiesBatch } from "./atproto"; +import { SITE } from "./lexicon"; +import { SERVICES } from "./shared"; +import { is } from "@atcute/lexicons/validations"; +import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; +import type { XyzAtbbsSite } from "../lexicons"; + +export interface DiscoveredBBS { + did: string; + handle: string; + name: string; + description: string; + avatar?: string; +} + +interface LightrailRepo { + did: string; +} + +export async function fetchDiscovery(): Promise { + let repos: LightrailRepo[] = []; + try { + const response = await fetch( + `${SERVICES.lightrail}/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`, + ); + const data = (await response.json()) as { repos: LightrailRepo[] }; + repos = data.repos; + } catch { + return []; + } + if (!repos.length) return []; + + const shuffled = repos.sort(() => Math.random() - 0.5); + const identities = await resolveIdentitiesBatch( + shuffled.map((repo) => repo.did), + ); + + const items: DiscoveredBBS[] = []; + for (const repo of shuffled) { + if (!(repo.did in identities)) continue; + try { + const siteRecord = await getRecord(repo.did, SITE, "self"); + if (!is(siteSchema, siteRecord.value)) continue; + const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; + items.push({ + did: repo.did, + handle: identities[repo.did].handle, + name: siteValue.name || identities[repo.did].handle, + description: siteValue.description || "", + }); + } catch { + continue; + } + } + + const avatars = await getAvatars(items.map((item) => item.did)); + for (const item of items) { + item.avatar = avatars[item.did]; + } + + return items; +} diff --git a/web/src/lib/home.ts b/web/src/lib/home.ts new file mode 100644 index 0000000..6ada1f4 --- /dev/null +++ b/web/src/lib/home.ts @@ -0,0 +1,20 @@ +/** Minimal check for the dashboard: does this user run a BBS, and if so + * what's it called? A full BBS fetch only happens on the BBS page itself. */ + +import { getRecord } from "./atproto"; +import { SITE } from "./lexicon"; + +export interface HomeSysopInfo { + hasBBS: boolean; + bbsName: string | null; +} + +export async function fetchHomeSysopInfo(did: string): Promise { + try { + const record = await getRecord(did, SITE, "self"); + const value = record.value as { name?: string }; + return { hasBBS: true, bbsName: value.name ?? null }; + } catch { + return { hasBBS: false, bbsName: null }; + } +} diff --git a/web/src/lib/news.ts b/web/src/lib/news.ts new file mode 100644 index 0000000..76ce6f6 --- /dev/null +++ b/web/src/lib/news.ts @@ -0,0 +1,41 @@ +/** Fetch the list of news posts a sysop has published to their site. */ + +import { getBacklinks, getRecordsBatch } from "./atproto"; +import { POST, SITE } from "./lexicon"; +import { makeAtUri, parseAtUri } from "./util"; +import { is } from "@atcute/lexicons/validations"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../lexicons"; +import type { NewsPost } from "./bbs"; + +export async function fetchNews(bbsDid: string): Promise { + const siteUri = makeAtUri(bbsDid, SITE, "self"); + const backlinks = await getBacklinks(siteUri, `${POST}:scope`, 50).catch( + () => null, + ); + if (!backlinks) return []; + + const sysopRefs = backlinks.records.filter((ref) => ref.did === bbsDid); + const records = await getRecordsBatch(sysopRefs); + + const news: NewsPost[] = records + .filter((record) => is(postSchema, record.value)) + .filter((record) => { + const value = record.value as unknown as XyzAtbbsPost.Main; + return value.title && !value.root; + }) + .map((record) => { + const value = record.value as unknown as XyzAtbbsPost.Main; + return { + uri: record.uri, + rkey: parseAtUri(record.uri).rkey, + title: value.title ?? "", + body: value.body, + createdAt: value.createdAt, + attachments: value.attachments as NewsPost["attachments"], + }; + }); + + news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return news; +} diff --git a/web/src/lib/queries.ts b/web/src/lib/queries.ts new file mode 100644 index 0000000..dbfe27b --- /dev/null +++ b/web/src/lib/queries.ts @@ -0,0 +1,149 @@ +/** Query-key factories. Every useQuery/useMutation in the app goes through + * one of these so query keys live in one place. */ + +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { + fetchIdentityDoc, + fetchAvatarUrl, + fetchBacklinkCount, +} from "./atproto"; +import { STALE_SLOW } from "./queryClient"; +import { resolveBBS } from "./bbs"; +import { fetchNews } from "./news"; +import { fetchProfile } from "./profile"; +import { fetchMyThreads } from "./mythreads"; +import { fetchActivity } from "./activity"; +import { fetchPins } from "./pins"; +import { fetchDiscovery } from "./discovery"; +import { fetchHomeSysopInfo } from "./home"; +import { fetchSysopModeration } from "./sysopModeration"; +import { fetchBBSModeration } from "./bbsModeration"; +import { hydrateThreadPage } from "./boardThreads"; +import { fetchThreadRefs, fetchThreadRoot, hydrateReplyPage } from "./thread"; +import type { BacklinkRef } from "./atproto"; + +// Shared by slow-changing queries: 5-minute staleTime, and skip the +// "refetch on every mount" default that live queries use. +const slowQueryOpts = { staleTime: STALE_SLOW, refetchOnMount: true } as const; + +export const bbsQuery = (handle: string) => + queryOptions({ + ...slowQueryOpts, + queryKey: ["bbs", handle] as const, + queryFn: () => resolveBBS(handle), + }); + +export const newsQuery = (bbsDid: string) => + queryOptions({ + queryKey: ["news", bbsDid] as const, + queryFn: () => fetchNews(bbsDid), + }); + +export const identityQuery = (identifier: string) => + queryOptions({ + ...slowQueryOpts, + queryKey: ["identity", identifier] as const, + queryFn: () => fetchIdentityDoc(identifier), + }); + +export const avatarQuery = (did: string) => + queryOptions({ + ...slowQueryOpts, + queryKey: ["avatar", did] as const, + queryFn: () => fetchAvatarUrl(did), + }); + +export const backlinkCountQuery = (subject: string, source: string) => + queryOptions({ + queryKey: ["backlink-count", source, subject] as const, + queryFn: () => fetchBacklinkCount(subject, source), + }); + +export const profileQuery = (handle: string) => + queryOptions({ + ...slowQueryOpts, + queryKey: ["profile", handle] as const, + queryFn: () => fetchProfile(handle), + }); + +export const myThreadsQuery = (pdsUrl: string, did: string) => + queryOptions({ + queryKey: ["my-threads", did] as const, + queryFn: () => fetchMyThreads(pdsUrl, did), + }); + +export const activityQuery = (pdsUrl: string, did: string) => + queryOptions({ + queryKey: ["activity", did] as const, + queryFn: () => fetchActivity(did, pdsUrl), + }); + +export const pinsQuery = (pdsUrl: string, did: string) => + queryOptions({ + queryKey: ["pins", did] as const, + queryFn: () => fetchPins(pdsUrl, did), + }); + +export const discoveryQuery = () => + queryOptions({ + queryKey: ["discovery"] as const, + queryFn: fetchDiscovery, + }); + +export const homeSysopQuery = (did: string) => + queryOptions({ + queryKey: ["home-sysop", did] as const, + queryFn: () => fetchHomeSysopInfo(did), + }); + +export const sysopModerationQuery = (pdsUrl: string, did: string) => + queryOptions({ + queryKey: ["sysop-moderation", did] as const, + queryFn: () => fetchSysopModeration(pdsUrl, did), + }); + +export const bbsModerationQuery = (pdsUrl: string, did: string) => + queryOptions({ + queryKey: ["bbs-moderation", did] as const, + queryFn: () => fetchBBSModeration(pdsUrl, did), + }); + +export const boardThreadsInfiniteQuery = (bbsDid: string, slug: string) => + infiniteQueryOptions({ + queryKey: ["board-threads", bbsDid, slug] as const, + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + hydrateThreadPage(bbsDid, slug, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last) => last.cursor ?? undefined, + refetchOnMount: "always", + }); + +export const threadRefsQuery = (threadUri: string) => + queryOptions({ + queryKey: ["thread-refs", threadUri] as const, + queryFn: () => fetchThreadRefs(threadUri), + }); + +export const threadRootQuery = (did: string, tid: string) => + queryOptions({ + queryKey: ["thread-root", did, tid] as const, + queryFn: () => fetchThreadRoot(did, tid), + }); + +export const threadPageQuery = ( + threadUri: string, + page: number, + pageRefs: BacklinkRef[], +) => + queryOptions({ + // Fingerprint is part of the key so that when the thread-refs cache + // gets updated (new replies, deletes), this page's cache entry gets + // a new key and refetches — rather than serving a stale hydration. + queryKey: [ + "thread-page", + threadUri, + page, + pageRefs.map((ref) => ref.rkey).join("/"), + ] as const, + queryFn: () => hydrateReplyPage(pageRefs), + }); diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts new file mode 100644 index 0000000..381898e --- /dev/null +++ b/web/src/lib/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** Stays fresh for 30s: posts, replies, activity, counts. */ +export const STALE_LIVE = 30 * 1000; + +/** Stays fresh for 5 min: identities, avatars, site records, profiles. */ +export const STALE_SLOW = 5 * 60 * 1000; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_LIVE, + // Every page navigation refetches live data. Slow queries opt out + // via `refetchOnMount: true` in queries.ts. + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }, + }, +}); diff --git a/web/src/router/loaders/sysop.ts b/web/src/lib/sysopModeration.ts similarity index 62% rename from web/src/router/loaders/sysop.ts rename to web/src/lib/sysopModeration.ts index 0ea8321..9638d51 100644 --- a/web/src/router/loaders/sysop.ts +++ b/web/src/lib/sysopModeration.ts @@ -1,17 +1,12 @@ -import { redirect } from "react-router-dom"; -import { resolveBBS, type BBS } from "../../lib/bbs"; -import { - getRecordByUri, - listRecords, - resolveIdentitiesBatch, -} from "../../lib/atproto"; -import { BAN, HIDE } from "../../lib/lexicon"; -import { parseAtUri } from "../../lib/util"; +/** Load a sysop's bans + hides, hydrated with identities and post previews. */ + +import { getRecordByUri, listRecords, resolveIdentitiesBatch } from "./atproto"; +import { BAN, HIDE } from "./lexicon"; +import { parseAtUri } from "./util"; import { is } from "@atcute/lexicons/validations"; -import { mainSchema as banSchema } from "../../lexicons/types/xyz/atbbs/ban"; -import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atbbs/hide"; -import type { XyzAtbbsBan, XyzAtbbsHide } from "../../lexicons"; -import { requireAuth } from "./auth"; +import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; +import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; +import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons"; export interface HiddenInfo { uri: string; @@ -20,6 +15,13 @@ export interface HiddenInfo { body: string; } +export interface SysopModeration { + banRkeys: Record; + bannedHandles: Record; + hideRkeys: Record; + hidden: HiddenInfo[]; +} + function buildRkeyMap( records: { uri: string; value: Record }[], schema: Parameters[0], @@ -47,7 +49,6 @@ async function hydrateHiddenPosts(uris: string[]): Promise { const did = parseAtUri(uri).did; const handle = identities[did]?.handle ?? did; const result = records[index]; - if (result.status === "fulfilled") { const value = result.value.value as unknown as { title?: string; @@ -60,34 +61,17 @@ async function hydrateHiddenPosts(uris: string[]): Promise { body: (value.body ?? "").substring(0, 100), }; } - return { uri, handle, title: "", body: uri }; }); } -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"); - } - +export async function fetchSysopModeration( + pdsUrl: string, + did: string, +): Promise { const [banRecs, hideRecs] = await Promise.all([ - listRecords(user.pdsUrl, user.did, BAN), - listRecords(user.pdsUrl, user.did, HIDE), + listRecords(pdsUrl, did, BAN), + listRecords(pdsUrl, did, HIDE), ]); const banRkeys = buildRkeyMap( @@ -113,8 +97,7 @@ export async function sysopModerateLoader() { } } - const hiddenUris = Object.keys(hideRkeys); - const hidden = await hydrateHiddenPosts(hiddenUris); + const hidden = await hydrateHiddenPosts(Object.keys(hideRkeys)); - return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden }; + return { banRkeys, bannedHandles, hideRkeys, hidden }; } diff --git a/web/src/lib/thread.ts b/web/src/lib/thread.ts new file mode 100644 index 0000000..1b660c7 --- /dev/null +++ b/web/src/lib/thread.ts @@ -0,0 +1,129 @@ +/** Thread detail fetchers: root post, reply refs, and the hydrated + * reply records for one page of the thread. */ + +import { + getBacklinks, + getRecord, + getRecordsBatch, + resolveIdentitiesBatch, + resolveIdentity, + type BacklinkRef, +} from "./atproto"; +import { POST } from "./lexicon"; +import { makeAtUri, parseAtUri } from "./util"; +import { recordToReply } from "./replies"; +import type { Reply } from "../components/post/ReplyCard"; +import { is } from "@atcute/lexicons/validations"; +import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; +import type { XyzAtbbsPost } from "../lexicons"; + +export interface ThreadRoot { + 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 }[]; +} + +const MAX_REF_PAGES = 20; +const REF_PAGE_SIZE = 100; + +/** Every reply ref for the thread, oldest-first. */ +export async function fetchThreadRefs( + threadUri: string, +): Promise { + const collected: BacklinkRef[] = []; + let cursor: string | undefined; + for (let i = 0; i < MAX_REF_PAGES; i++) { + const page = await getBacklinks( + threadUri, + `${POST}:root`, + REF_PAGE_SIZE, + cursor, + ); + collected.push(...page.records); + if (!page.cursor) break; + cursor = page.cursor; + } + return collected.reverse(); +} + +export async function fetchThreadRoot( + did: string, + tid: string, +): Promise { + const threadRecord = await getRecord(did, POST, tid); + if (!is(postSchema, threadRecord.value)) { + throw new Error("Invalid post record"); + } + const author = await resolveIdentity(did); + const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; + const boardSlug = parseAtUri(postValue.scope).rkey; + return { + uri: threadRecord.uri, + did, + rkey: tid, + authorHandle: author.handle, + authorPds: author.pds ?? "", + title: postValue.title ?? "", + body: postValue.body, + createdAt: postValue.createdAt, + boardSlug, + attachments: postValue.attachments as ThreadRoot["attachments"], + }; +} + +export function threadUriFor(did: string, tid: string): string { + return makeAtUri(did, POST, tid); +} + +export interface ReplyPage { + replies: Reply[]; + /** Lookup by URI for any reply referenced as a parent — includes both + * on-page replies and off-page parents fetched separately. */ + parentReplies: Record; +} + +export async function hydrateReplyPage( + pageRefs: BacklinkRef[], +): Promise { + if (!pageRefs.length) return { replies: [], parentReplies: {} }; + + const records = await getRecordsBatch(pageRefs); + const authors = await resolveIdentitiesBatch( + records.map((r) => parseAtUri(r.uri).did), + ); + const replies: Reply[] = records + .map((record) => recordToReply(record, authors)) + .filter((reply): reply is Reply => reply !== null) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + + const parentReplies: Record = {}; + for (const reply of replies) parentReplies[reply.uri] = reply; + + const offPageParentUris = [ + ...new Set( + replies + .map((r) => r.parent) + .filter((uri): uri is string => !!uri && !parentReplies[uri]), + ), + ]; + if (offPageParentUris.length) { + const parentRefs = offPageParentUris.map((uri) => parseAtUri(uri)); + const parentRecords = await getRecordsBatch(parentRefs); + const parentAuthors = await resolveIdentitiesBatch( + parentRecords.map((r) => parseAtUri(r.uri).did), + ); + for (const record of parentRecords) { + const reply = recordToReply(record, parentAuthors); + if (reply) parentReplies[reply.uri] = reply; + } + } + + return { replies, parentReplies }; +} diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index e280e93..0847dba 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -29,6 +29,11 @@ export function parseAtUri(uri: string): { return { did: parts[2], collection: parts[3], rkey: parts[4] }; } +export function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + "..."; +} + import type { Did } from "@atcute/lexicons/syntax"; export function makeAtUri( diff --git a/web/src/lib/writes.ts b/web/src/lib/writes.ts index 3ee15df..5a6aace 100644 --- a/web/src/lib/writes.ts +++ b/web/src/lib/writes.ts @@ -2,7 +2,7 @@ import type { Client } from "@atcute/client"; import { SITE, BOARD, POST, BAN, HIDE, PIN, PROFILE } from "./lexicon"; -import { invalidateBBSCache } from "./bbs"; +import { invalidateAllBBSCaches } from "./bbs"; import { nowIso } from "./util"; import { getCurrentUser } from "./auth"; import type { @@ -188,7 +188,7 @@ export async function createPost( export async function putSite(rpc: Client, site: SiteValue) { const resp = await putRecord(rpc, SITE, "self", site); - invalidateBBSCache(); + invalidateAllBBSCaches(); return resp; } @@ -205,7 +205,7 @@ export async function putBoard( createdAt: createdAt as BoardValue["createdAt"], }; const resp = await putRecord(rpc, BOARD, slug, value); - invalidateBBSCache(); + invalidateAllBBSCaches(); return resp; } @@ -217,7 +217,7 @@ export async function createBan(rpc: Client, did: string) { createdAt: nowIso(), }; const resp = await createRecord(rpc, BAN, value); - invalidateBBSCache(); + invalidateAllBBSCaches(); return resp; } @@ -227,7 +227,7 @@ export async function createHide(rpc: Client, uri: string) { createdAt: nowIso(), }; const resp = await createRecord(rpc, HIDE, value); - invalidateBBSCache(); + invalidateAllBBSCaches(); return resp; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 4dcb245..dbb0882 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router-dom"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { queryClient } from "./lib/queryClient"; import { router } from "./router/routes"; import { BreadcrumbProvider } from "./hooks/useBreadcrumb"; import "./index.css"; @@ -11,8 +14,13 @@ window.addEventListener("pageshow", (event) => { createRoot(document.getElementById("root")!).render( - - - + + + + + {import.meta.env.DEV && ( + + )} + , ); diff --git a/web/src/pages/BBS.tsx b/web/src/pages/BBS.tsx index 98c948e..c547550 100644 --- a/web/src/pages/BBS.tsx +++ b/web/src/pages/BBS.tsx @@ -1,18 +1,6 @@ import { useState, type SyntheticEvent } from "react"; -import { Link, useLocation, useRouteLoaderData } from "react-router-dom"; -import { useAuth } from "../lib/auth"; -import { useBreadcrumb } from "../hooks/useBreadcrumb"; -import { createPost, uploadAttachments } from "../lib/writes"; -import ComposeForm from "../components/form/ComposeForm"; -import { SITE } from "../lib/lexicon"; -import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; -import * as limits from "../lib/limits"; -import { usePageTitle } from "../hooks/usePageTitle"; -import Localtime from "../components/Localtime"; -import ListLink from "../components/nav/ListLink"; -import ActionBar from "../components/nav/ActionBar"; -import { ActionLink } from "../components/nav/ActionButton"; -import PinButton from "../components/PinButton"; +import { Link, useParams } from "react-router-dom"; +import { useSuspenseQuery, useMutation, useQuery } from "@tanstack/react-query"; import { User, Pencil, @@ -22,23 +10,41 @@ import { Megaphone, ChevronDown, } from "lucide-react"; +import { useAuth } from "../lib/auth"; +import { useBreadcrumb } from "../hooks/useBreadcrumb"; +import { usePageTitle } from "../hooks/usePageTitle"; +import { createPost, uploadAttachments } from "../lib/writes"; +import { findPinRkey } from "../lib/pins"; +import { SITE } from "../lib/lexicon"; +import { makeAtUri, nowIso, parseAtUri, truncate } from "../lib/util"; +import * as limits from "../lib/limits"; +import { bbsQuery, newsQuery, pinsQuery } from "../lib/queries"; +import { queryClient } from "../lib/queryClient"; import type { NewsPost } from "../lib/bbs"; -import type { BBSLoaderData } from "../router/loaders"; -import PostBody from "../components/post/PostBody"; +import ComposeForm from "../components/form/ComposeForm"; +import Localtime from "../components/Localtime"; +import ListLink from "../components/nav/ListLink"; +import ActionBar from "../components/nav/ActionBar"; +import { ActionLink } from "../components/nav/ActionButton"; +import PinButton from "../components/PinButton"; + +const INITIAL_NEWS_COUNT = 3; export default function BBSPage() { - const { handle, bbs, pinRkey } = useRouteLoaderData("bbs") as BBSLoaderData; + const { handle } = useParams(); const { user, agent } = useAuth(); - // Set when arriving here via a news delete; Constellation/Slingshot may - // still be returning the record, so filter it out for this one render. - const justDeletedRkey = (useLocation().state as { deletedNewsRkey?: string }) - ?.deletedNewsRkey; const [newsTitle, setNewsTitle] = useState(""); const [newsBody, setNewsBody] = useState(""); const [newsFiles, setNewsFiles] = useState([]); - const [pendingNews, setPendingNews] = useState([]); const [showAllNews, setShowAllNews] = useState(false); - const [postingNews, setPostingNews] = useState(false); + + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); + const { data: news } = useSuspenseQuery(newsQuery(bbs.identity.did)); + const { data: pins } = useQuery({ + ...pinsQuery(user?.pdsUrl ?? "", user?.did ?? ""), + enabled: !!user, + }); + const pinRkey = user && pins ? findPinRkey(pins, bbs.identity.did) : null; useBreadcrumb( [{ label: bbs.site.name, to: `/bbs/${handle}` }], @@ -48,44 +54,59 @@ export default function BBSPage() { const isSysop = user && user.did === bbs.identity.did; - async function postNews(e: SyntheticEvent) { - e.preventDefault(); - if (!agent || postingNews) return; - setPostingNews(true); - try { - const title = newsTitle.trim(); - const body = newsBody.trim(); + const postNewsMutation = useMutation({ + mutationFn: async (input: { + title: string; + body: string; + files: File[]; + }) => { + if (!agent) throw new Error("Not signed in"); const siteUri = makeAtUri(bbs.identity.did, SITE, "self"); - const attachments = await uploadAttachments(agent, newsFiles); - const resp = await createPost(agent, siteUri, body, { - title, + const attachments = await uploadAttachments(agent, input.files); + const resp = await createPost(agent, siteUri, input.body, { + title: input.title, attachments, }); + return { resp, attachments }; + }, + onSuccess: ({ resp, attachments }, input) => { const rkey = parseAtUri(resp.data.uri).rkey; - setPendingNews((prev) => [ - { uri: resp.data.uri, rkey, title, body, createdAt: nowIso() }, - ...prev, - ]); + const newItem: NewsPost = { + uri: resp.data.uri, + rkey, + title: input.title, + body: input.body, + createdAt: nowIso(), + attachments: attachments.length + ? (attachments as NewsPost["attachments"]) + : undefined, + }; + queryClient.setQueryData( + newsQuery(bbs.identity.did).queryKey, + (prev) => [newItem, ...(prev ?? [])], + ); setNewsTitle(""); setNewsBody(""); setNewsFiles([]); - } catch (error: unknown) { + }, + onError: (error) => { alert( `Could not post: ${error instanceof Error ? error.message : error}`, ); - } finally { - setPostingNews(false); - } + }, + }); + + function onPostNews(event: SyntheticEvent) { + event.preventDefault(); + if (postNewsMutation.isPending) return; + postNewsMutation.mutate({ + title: newsTitle.trim(), + body: newsBody.trim(), + files: newsFiles, + }); } - // Merge pending news with loader data, deduplicating by rkey and dropping - // anything just deleted from the News page. - const loaderTids = new Set(bbs.news.map((n) => n.rkey)); - const allNews = [ - ...pendingNews.filter((n) => !loaderTids.has(n.rkey)), - ...bbs.news, - ].filter((n) => n.rkey !== justDeletedRkey); - const visibleNews = showAllNews ? allNews : allNews.slice(0, 3); + const visibleNews = showAllNews ? news : news.slice(0, INITIAL_NEWS_COUNT); return ( <> @@ -93,8 +114,15 @@ export default function BBSPage() {

{bbs.site.name}

{bbs.site.description}

- - + + owner {isSysop && ( @@ -144,7 +172,7 @@ export default function BBSPage() { )} - {allNews.length ? ( + {news.length ? ( <> {visibleNews.map((item, i) => (
@@ -177,12 +204,11 @@ export default function BBSPage() {
- {item.body.substring(0, 200) + - (item.body.length > 200 ? "..." : "")} + {truncate(item.body, 200)}
))} - {!showAllNews && allNews.length > 3 && ( + {!showAllNews && news.length > INITIAL_NEWS_COUNT && (
- {cursor && ( + {hasNextPage && (
)} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 415d4e9..c2d59e8 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,9 +1,17 @@ -import { Await, useRevalidator } from "react-router-dom"; -import { Suspense, useEffect, useMemo, useState } from "react"; -import { useAuth } from "../lib/auth"; +import { useMemo, useState } from "react"; +import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; +import { useAuth, type AuthUser } from "../lib/auth"; import { deleteBBS } from "../lib/deletebbs"; -import { useDiscovery } from "../hooks/useDiscovery"; import { usePageTitle } from "../hooks/usePageTitle"; +import { + activityQuery, + discoveryQuery, + homeSysopQuery, + myThreadsQuery, + pinsQuery, +} from "../lib/queries"; +import { queryClient } from "../lib/queryClient"; +import { invalidateAllBBSCaches } from "../lib/bbs"; import DialBBS, { bbsToSuggestion, type Suggestion, @@ -12,17 +20,6 @@ import PinnedList from "../components/dashboard/PinnedList"; import MyThreadList from "../components/dashboard/MyThreadList"; import ActivityList from "../components/dashboard/ActivityList"; import BBSPanel from "../components/dashboard/BBSPanel"; -import type { ActivityItem, PinnedBBS, MyThread } from "../router/loaders"; -import type { AuthUser } from "../lib/auth"; - -export interface DashboardData { - user: AuthUser; - hasBBS: boolean; - bbsName: string | null; - pins: Promise; - threads: Promise; - activity: Promise; -} type Tab = "inbox" | "threads" | "pinned" | "bbs"; @@ -31,51 +28,59 @@ const TAB_STYLE_ACTIVE = const TAB_STYLE_INACTIVE = "py-2 border-b-2 text-neutral-400 hover:text-neutral-300 border-transparent whitespace-nowrap"; -export default function Dashboard({ - user, - hasBBS, - bbsName, - pins: pinsPromise, - threads: threadsPromise, - activity: activityPromise, -}: DashboardData) { +interface DashboardProps { + user: AuthUser; +} + +export default function Dashboard({ user }: DashboardProps) { const { agent } = useAuth(); - const revalidator = useRevalidator(); - const discoveredBBSes = useDiscovery(); const [tab, setTab] = useState("inbox"); - const [pins, setPins] = useState([]); usePageTitle("atbbs"); - useEffect(() => { - pinsPromise.then(setPins); - }, [pinsPromise]); + const { data: sysopInfo } = useSuspenseQuery(homeSysopQuery(user.did)); + const { data: pins } = useSuspenseQuery(pinsQuery(user.pdsUrl, user.did)); + const { data: threads } = useSuspenseQuery( + myThreadsQuery(user.pdsUrl, user.did), + ); + const { data: activity } = useSuspenseQuery( + activityQuery(user.pdsUrl, user.did), + ); + const { data: discovered } = useSuspenseQuery(discoveryQuery()); const suggestions = useMemo(() => { const pinnedDids = new Set(pins.map((pin) => pin.did)); const fromPins = pins.map(bbsToSuggestion); - const fromDiscovery = discoveredBBSes + const fromDiscovery = discovered .filter((bbs) => !pinnedDids.has(bbs.did)) .slice(0, 5) .map(bbsToSuggestion); return [...fromPins, ...fromDiscovery]; - }, [pins, discoveredBBSes]); + }, [pins, discovered]); + + const deleteBBSMutation = useMutation({ + mutationFn: async () => { + if (!agent) throw new Error("Not signed in"); + await deleteBBS(agent, user.did, user.pdsUrl); + }, + onSuccess: () => { + queryClient.invalidateQueries(homeSysopQuery(user.did)); + invalidateAllBBSCaches(); + }, + onError: (error: unknown) => { + alert( + error instanceof Error ? error.message : "Could not delete community.", + ); + }, + }); - async function handleDeleteBBS() { - if (!agent) return; + function handleDeleteBBS() { if ( !confirm( "Are you sure? This will delete your site record, all board records, and all news records. Threads and replies from users will remain in their repos.", ) ) return; - try { - await deleteBBS(agent, user.did, user.pdsUrl); - revalidator.revalidate(); - } catch (error) { - alert( - error instanceof Error ? error.message : "Could not delete community.", - ); - } + deleteBBSMutation.mutate(); } const tabs: { key: Tab; label: string }[] = [ @@ -85,12 +90,10 @@ export default function Dashboard({ { key: "bbs", label: "Community" }, ]; - const loadingFallback =

loading...

; - return ( <>
- +
Recent replies from other users.

- - - {(items: ActivityItem[]) => ( - - )} - - + )} @@ -132,11 +129,7 @@ export default function Dashboard({

Threads you've posted across all communities.

- - - {(threads: MyThread[]) => } - - + )} @@ -145,11 +138,7 @@ export default function Dashboard({

Communities you've pinned for quick access.

- - - {(pins: PinnedBBS[]) => } - - + )} @@ -159,10 +148,10 @@ export default function Dashboard({ Manage your community.

diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index c1ec673..130c538 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,15 +1,9 @@ -import { useLoaderData } from "react-router-dom"; -import Dashboard, { type DashboardData } from "./Dashboard"; +import { useAuth } from "../lib/auth"; +import Dashboard from "./Dashboard"; import LoggedOutHome from "./LoggedOutHome"; -interface HomeLoaderData { - user: DashboardData["user"] | null; -} - export default function Home() { - const data = useLoaderData() as HomeLoaderData; - - if (data.user) return ; - - return ; + const { status, user } = useAuth(); + if (status === "loading") return null; + return user ? : ; } diff --git a/web/src/pages/LoggedOutHome.tsx b/web/src/pages/LoggedOutHome.tsx index 6210305..0a2b9f4 100644 --- a/web/src/pages/LoggedOutHome.tsx +++ b/web/src/pages/LoggedOutHome.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from "react"; import { Phone, Copy, Check } from "lucide-react"; -import { useDiscovery } from "../hooks/useDiscovery"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { usePageTitle } from "../hooks/usePageTitle"; +import { discoveryQuery } from "../lib/queries"; import DialBBS, { bbsToSuggestion, type Suggestion, @@ -9,7 +10,7 @@ import DialBBS, { import DiscoveryList from "../components/dashboard/DiscoveryList"; export default function LoggedOutHome() { - const discovered = useDiscovery(); + const { data: discovered } = useSuspenseQuery(discoveryQuery()); const suggestions = useMemo( () => discovered.map(bbsToSuggestion), [discovered], diff --git a/web/src/pages/News.tsx b/web/src/pages/News.tsx index dfcb593..559f6c9 100644 --- a/web/src/pages/News.tsx +++ b/web/src/pages/News.tsx @@ -1,31 +1,23 @@ -import { - useLocation, - useNavigate, - useParams, - useRouteLoaderData, -} from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; +import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; import { useAuth } from "../lib/auth"; import { useBreadcrumb } from "../hooks/useBreadcrumb"; import { usePageTitle } from "../hooks/usePageTitle"; import { POST } from "../lib/lexicon"; import { deleteRecord } from "../lib/writes"; -import { invalidateBBSCache, type NewsPost } from "../lib/bbs"; -import type { BBSLoaderData } from "../router/loaders"; +import { bbsQuery, newsQuery } from "../lib/queries"; +import { queryClient } from "../lib/queryClient"; +import type { NewsPost } from "../lib/bbs"; import NewsCard from "../components/post/NewsCard"; export default function NewsPage() { const { handle, tid } = useParams(); - const { bbs } = useRouteLoaderData("bbs") as BBSLoaderData; const { user, agent } = useAuth(); const navigate = useNavigate(); - // Fallback for posts that were just created but haven't made it into the - // cached BBS loader data yet. - const stateItem = (useLocation().state as { pendingNewsItem?: NewsPost }) - ?.pendingNewsItem; - const item = - bbs.news.find((news) => news.rkey === tid) ?? - (stateItem?.rkey === tid ? stateItem : undefined); + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); + const { data: news } = useSuspenseQuery(newsQuery(bbs.identity.did)); + const item = news.find((n) => n.rkey === tid); useBreadcrumb( [ @@ -38,18 +30,29 @@ export default function NewsPage() { item ? `${item.title} — ${bbs.site.name}` : `News — ${bbs.site.name}`, ); - if (!item) { - return

News post not found.

; - } - const isSysop = !!(user && user.did === bbs.identity.did); - async function onDelete() { - if (!agent || !tid) return; - if (!confirm("Delete this news post?")) return; - await deleteRecord(agent, POST, tid); - invalidateBBSCache(); - navigate(`/bbs/${handle}`, { state: { deletedNewsRkey: tid } }); + const deleteNewsMutation = useMutation({ + mutationFn: async () => { + if (!agent || !tid) throw new Error("Not signed in"); + await deleteRecord(agent, POST, tid); + }, + onSuccess: () => { + queryClient.setQueryData( + newsQuery(bbs.identity.did).queryKey, + (prev) => (prev ?? []).filter((n) => n.rkey !== tid), + ); + navigate(`/bbs/${handle}`); + }, + onError: (error) => { + alert( + `Could not delete: ${error instanceof Error ? error.message : error}`, + ); + }, + }); + + if (!item) { + return

News post not found.

; } return ( @@ -59,7 +62,10 @@ export default function NewsPage() { pds={bbs.identity.pds ?? ""} did={bbs.identity.did} isSysop={isSysop} - onDelete={onDelete} + onDelete={() => { + if (!confirm("Delete this news post?")) return; + deleteNewsMutation.mutate(); + }} /> ); } diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 63e7090..d338404 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -1,29 +1,44 @@ -import { Suspense, useState } from "react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; import { MessageSquare } from "lucide-react"; -import { Await, useLoaderData, useRevalidator } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { usePageTitle } from "../hooks/usePageTitle"; import { putProfile } from "../lib/writes"; +import { myThreadsQuery, profileQuery } from "../lib/queries"; +import { queryClient } from "../lib/queryClient"; import ViewProfile from "../components/profile/ViewProfile"; import EditProfile from "../components/profile/EditProfile"; import MyThreadList from "../components/dashboard/MyThreadList"; -import type { MyThread } from "../lib/mythreads"; -import type { ProfileLoaderData } from "../router/loaders/profile"; export default function Profile() { - const { handle, profile, threads } = useLoaderData() as ProfileLoaderData; + const { handle } = useParams(); const { user, agent } = useAuth(); - const revalidator = useRevalidator(); - const isOwner = user?.handle === handle; const [editing, setEditing] = useState(false); + + const { data: profile } = useSuspenseQuery(profileQuery(handle!)); + const { data: threads } = useSuspenseQuery( + myThreadsQuery(profile?.pdsUrl ?? "", profile?.did ?? ""), + ); + usePageTitle(`${profile?.name ?? handle} — atbbs`); - async function handleSave(name?: string, pronouns?: string, bio?: string) { - if (!agent) return; - await putProfile(agent, name, pronouns, bio); - setEditing(false); - revalidator.revalidate(); - } + const isOwner = user?.handle === handle; + + const saveProfileMutation = useMutation({ + mutationFn: async (input: { + name?: string; + pronouns?: string; + bio?: string; + }) => { + if (!agent) throw new Error("Not signed in"); + await putProfile(agent, input.name, input.pronouns, input.bio); + }, + onSuccess: () => { + queryClient.invalidateQueries(profileQuery(handle!)); + setEditing(false); + }, + }); if (editing) { return ( @@ -31,7 +46,9 @@ export default function Profile() { initialName={profile?.name ?? ""} initialPronouns={profile?.pronouns ?? ""} initialBio={profile?.bio ?? ""} - onSave={handleSave} + onSave={(name, pronouns, bio) => + saveProfileMutation.mutateAsync({ name, pronouns, bio }) + } onCancel={() => setEditing(false)} /> ); @@ -40,7 +57,7 @@ export default function Profile() { return ( <> setEditing(true)} @@ -49,13 +66,7 @@ export default function Profile() {

Recent Threads

- loading...

}> - - {(resolved: MyThread[]) => ( - - )} - -
+
); diff --git a/web/src/pages/SysopCreate.tsx b/web/src/pages/SysopCreate.tsx index 85bbd4b..aaecf37 100644 --- a/web/src/pages/SysopCreate.tsx +++ b/web/src/pages/SysopCreate.tsx @@ -1,5 +1,5 @@ import { useState, type SyntheticEvent } from "react"; -import { useNavigate, useLoaderData } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuth } from "../lib/auth"; import { putBoard, putSite } from "../lib/writes"; import { BOARD } from "../lib/lexicon"; @@ -11,11 +11,9 @@ import { Input, Textarea, Button } from "../components/form/Form"; import BoardRowEditor, { type BoardRow, } from "../components/form/BoardRowEditor"; -import type { AuthUser } from "../lib/auth"; export default function SysopCreate() { - const { user } = useLoaderData() as { user: AuthUser }; - const { agent } = useAuth(); + const { user, agent } = useAuth(); const navigate = useNavigate(); const [name, setName] = useState(""); @@ -34,7 +32,7 @@ export default function SysopCreate() { async function onSubmit(e: SyntheticEvent) { e.preventDefault(); - if (!agent) return; + if (!agent || !user) return; const cleanBoards = boards .map((board) => ({ slug: board.slug.trim(), diff --git a/web/src/pages/SysopEdit.tsx b/web/src/pages/SysopEdit.tsx index deac402..cd4748e 100644 --- a/web/src/pages/SysopEdit.tsx +++ b/web/src/pages/SysopEdit.tsx @@ -1,28 +1,26 @@ import { useState, type SyntheticEvent } from "react"; -import { useLoaderData, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { useAuth } from "../lib/auth"; import { putBoard, putSite } from "../lib/writes"; import { BOARD } from "../lib/lexicon"; import { makeAtUri, nowIso } from "../lib/util"; import * as limits from "../lib/limits"; import { usePageTitle } from "../hooks/usePageTitle"; +import { bbsQuery } from "../lib/queries"; import { Input, Textarea, Button } from "../components/form/Form"; import BoardRowEditor, { type BoardRow, } from "../components/form/BoardRowEditor"; -import type { BBS } from "../lib/bbs"; -import type { AuthUser } from "../lib/auth"; - -interface LoaderData { - user: AuthUser; - bbs: BBS; -} export default function SysopEdit() { - const { user, bbs } = useLoaderData() as LoaderData; - const { agent } = useAuth(); + const { user, agent } = useAuth(); const navigate = useNavigate(); + // requireAuthLoader has already redirected unauthenticated users, so + // `user` is non-null at render time. + const { data: bbs } = useSuspenseQuery(bbsQuery(user!.handle)); + const [name, setName] = useState(bbs.site.name); const [description, setDescription] = useState(bbs.site.description); const [intro, setIntro] = useState(bbs.site.intro); @@ -39,7 +37,7 @@ export default function SysopEdit() { async function onSubmit(e: SyntheticEvent) { e.preventDefault(); - if (!agent || !name.trim()) return; + if (!agent || !user || !name.trim()) return; const cleanBoards = boards .map((board) => ({ slug: board.slug.trim(), diff --git a/web/src/pages/SysopModerate.tsx b/web/src/pages/SysopModerate.tsx index 2976775..0862ecf 100644 --- a/web/src/pages/SysopModerate.tsx +++ b/web/src/pages/SysopModerate.tsx @@ -1,85 +1,108 @@ import { useState } from "react"; -import { useLoaderData, useRevalidator } from "react-router-dom"; +import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; import { useAuth } from "../lib/auth"; import { resolveIdentity } from "../lib/atproto"; import { BAN, HIDE } from "../lib/lexicon"; -import { invalidateBBSCache } from "../lib/bbs"; +import { invalidateAllBBSCaches } from "../lib/bbs"; +import { bbsQuery, sysopModerationQuery } from "../lib/queries"; +import { queryClient } from "../lib/queryClient"; import HandleInput from "../components/form/HandleInput"; import { Button } from "../components/form/Form"; import { usePageTitle } from "../hooks/usePageTitle"; import { createBan, createHide, deleteRecord } from "../lib/writes"; -import type { BBS } from "../lib/bbs"; -import type { AuthUser } from "../lib/auth"; -import type { HiddenInfo } from "../router/loaders"; - -interface LoaderData { - user: AuthUser; - bbs: BBS; - banRkeys: Record; - bannedHandles: Record; - hideRkeys: Record; - hidden: HiddenInfo[]; -} export default function SysopModerate() { - const { bbs, banRkeys, bannedHandles, hideRkeys, hidden } = - useLoaderData() as LoaderData; - const { agent } = useAuth(); - const revalidator = useRevalidator(); + const { user, agent } = useAuth(); const [identifier, setIdentifier] = useState(""); const [hideUri, setHideUri] = useState(""); usePageTitle("Moderate community — atbbs"); - async function ban() { - if (!agent) return; - let id = identifier.trim(); + // requireAuthLoader guarantees user is present at render time. + const { data: bbs } = useSuspenseQuery(bbsQuery(user!.handle)); + const { data: moderation } = useSuspenseQuery( + sysopModerationQuery(user!.pdsUrl, user!.did), + ); + const { banRkeys, bannedHandles, hideRkeys, hidden } = moderation; + + function refreshModeration() { + queryClient.invalidateQueries( + sysopModerationQuery(user!.pdsUrl, user!.did), + ); + invalidateAllBBSCaches(); + } + + const banMutation = useMutation({ + mutationFn: async (identifier: string) => { + if (!agent) throw new Error("Not signed in"); + let did = identifier; + if (!did.startsWith("did:")) did = (await resolveIdentity(did)).did; + await createBan(agent, did); + }, + onSuccess: () => { + setIdentifier(""); + refreshModeration(); + }, + onError: (err) => + alert(`Could not ban: ${err instanceof Error ? err.message : err}`), + }); + + const unbanMutation = useMutation({ + mutationFn: async (rkey: string) => { + if (!agent) throw new Error("Not signed in"); + await deleteRecord(agent, BAN, rkey); + }, + onSuccess: refreshModeration, + }); + + const hideMutation = useMutation({ + mutationFn: async (uri: string) => { + if (!agent) throw new Error("Not signed in"); + await createHide(agent, uri); + }, + onSuccess: () => { + setHideUri(""); + refreshModeration(); + }, + }); + + const unhideMutation = useMutation({ + mutationFn: async (rkey: string) => { + if (!agent) throw new Error("Not signed in"); + await deleteRecord(agent, HIDE, rkey); + }, + onSuccess: refreshModeration, + }); + + function ban() { + const id = identifier.trim(); if (!id) return; - if (!id.startsWith("did:")) { - try { - id = (await resolveIdentity(id)).did; - } catch { - alert("Could not resolve handle."); - return; - } - } - await createBan(agent, id); - setIdentifier(""); - revalidator.revalidate(); + banMutation.mutate(id); } - async function unban(rkey: string) { - if (!agent) return; + function unban(rkey: string) { if (!confirm("Unban this user?")) return; - await deleteRecord(agent, BAN, rkey); - invalidateBBSCache(); - revalidator.revalidate(); + unbanMutation.mutate(rkey); } - async function hide() { - if (!agent) return; + function hide() { const u = hideUri.trim(); if (!u.startsWith("at://")) { alert("Enter a valid AT-URI."); return; } - await createHide(agent, u); - setHideUri(""); - revalidator.revalidate(); + hideMutation.mutate(u); } - async function unhide(rkey: string) { - if (!agent) return; + function unhide(rkey: string) { if (!confirm("Unhide this post?")) return; - await deleteRecord(agent, HIDE, rkey); - invalidateBBSCache(); - revalidator.revalidate(); + unhideMutation.mutate(rkey); } return ( <>

Moderate community

- Manage banned users and hidden posts. + Manage banned users and hidden posts for {bbs.site.name}.

diff --git a/web/src/pages/Thread.tsx b/web/src/pages/Thread.tsx index d1752da..a6f091d 100644 --- a/web/src/pages/Thread.tsx +++ b/web/src/pages/Thread.tsx @@ -1,16 +1,12 @@ import { useState, type SyntheticEvent } from "react"; -import { - useLoaderData, - useNavigate, - useRevalidator, - useRouteLoaderData, -} from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; +import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; import { useAuth } from "../lib/auth"; import { useBreadcrumb } from "../hooks/useBreadcrumb"; import { usePageTitle } from "../hooks/usePageTitle"; import { useThreadReplies } from "../hooks/useThreadReplies"; -import { BOARD, POST } from "../lib/lexicon"; -import { makeAtUri, parseAtUri } from "../lib/util"; +import { BAN, BOARD, HIDE, POST } from "../lib/lexicon"; +import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; import * as limits from "../lib/limits"; import { createBan, @@ -19,49 +15,58 @@ import { deleteRecord, uploadAttachments, } from "../lib/writes"; -import type { BBSLoaderData, ThreadObj } from "../router/loaders"; +import { + bbsModerationQuery, + bbsQuery, + threadPageQuery, + threadRefsQuery, + threadRootQuery, +} from "../lib/queries"; +import { queryClient } from "../lib/queryClient"; +import { threadUriFor } from "../lib/thread"; +import { REPLIES_PER_PAGE, refToUri } from "../lib/replies"; +import { invalidateAllBBSCaches } from "../lib/bbs"; +import type { BacklinkRef } from "../lib/atproto"; +import type { ReplyPage } from "../lib/thread"; +import type { BBS } from "../lib/bbs"; import PageNav from "../components/nav/PageNav"; import ReplyCard, { type Reply } from "../components/post/ReplyCard"; import ComposeForm from "../components/form/ComposeForm"; import ThreadCard from "../components/post/ThreadCard"; -interface LoaderData { - handle: string; - bbs: BBSLoaderData["bbs"]; - thread: ThreadObj; - allRefs: { did: string; collection: string; rkey: string }[]; -} - -/** - * Outer wrapper: re-keys the inner page on thread URI so navigating between - * threads gives us a fresh component instance (and fresh hook state). Without - * this, react-router reuses the same Thread component on param change and - * state from the previous thread (page index, optimistic adds) bleeds in. - */ -export default function ThreadRoute() { - const loaded = useLoaderData() as LoaderData; - return ; -} - -function ThreadPage({ loaded }: { loaded: LoaderData }) { - const { bbs } = useRouteLoaderData("bbs") as BBSLoaderData; - const { handle, thread } = loaded; +export default function ThreadPage() { + const { handle, did, tid } = useParams(); + const threadUri = threadUriFor(did!, tid!); const { user, agent } = useAuth(); - const revalidator = useRevalidator(); const navigate = useNavigate(); + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); + const { data: thread } = useSuspenseQuery(threadRootQuery(did!, tid!)); + const { data: moderation } = useSuspenseQuery( + bbsModerationQuery(bbs.identity.pds ?? "", bbs.identity.did), + ); const { page, setPage, totalPages, - replies, - loading: loadingPage, refs, - replyCache, + replies, + parentReplies, scrollToReply, - addOptimisticReply, - removeReply, - } = useThreadReplies(loaded); + } = useThreadReplies(threadUri); + + const isSysop = !!(user && user.did === bbs.identity.did); + const threadHidden = + !isSysop && + (moderation.bannedDids.has(thread.did) || + moderation.hiddenUris.has(thread.uri)); + const visibleReplies = isSysop + ? replies + : replies.filter( + (reply) => + !moderation.bannedDids.has(reply.did) && + !moderation.hiddenUris.has(reply.uri), + ); const [body, setBody] = useState(""); const [files, setFiles] = useState([]); @@ -69,80 +74,176 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { uri: string; handle: string; } | null>(null); - const [posting, setPosting] = useState(false); usePageTitle(`${thread.title} — ${bbs.site.name}`); - useBreadcrumb(buildBreadcrumb(bbs, thread, handle), [bbs, thread, handle]); + useBreadcrumb(buildBreadcrumb(bbs, thread.title, thread.boardSlug, handle!), [ + bbs, + thread, + handle, + ]); - const isSysop = user && user.did === bbs.identity.did; + // --- Mutations --- - async function onReply(e: SyntheticEvent) { - e.preventDefault(); - if (!agent || !user) return; - setPosting(true); - try { - const threadUri = makeAtUri(thread.did, POST, thread.rkey); - const attachments = await uploadAttachments(agent, files); + const createReplyMutation = useMutation({ + mutationFn: async (input: { + body: string; + parent: string | null; + files: File[]; + }) => { + if (!agent || !user) throw new Error("Not signed in"); const boardUri = makeAtUri(bbs.identity.did, BOARD, thread.boardSlug); - const resp = await createPost(agent, boardUri, body.trim(), { + const attachments = await uploadAttachments(agent, input.files); + const resp = await createPost(agent, boardUri, input.body, { root: threadUri, - parent: replyingTo?.uri ?? undefined, + parent: input.parent ?? undefined, attachments, }); - addOptimisticReply({ + return { resp, input, attachments }; + }, + onSuccess: ({ resp, input, attachments }) => { + if (!user) return; + const { did: newDid, rkey: newRkey } = parseAtUri(resp.data.uri); + const newRef: BacklinkRef = { + did: newDid, + collection: POST, + rkey: newRkey, + }; + const newReply: Reply = { uri: resp.data.uri, - did: parseAtUri(resp.data.uri).did, - rkey: parseAtUri(resp.data.uri).rkey, + did: newDid, + rkey: newRkey, handle: user.handle, pds: user.pdsUrl, - body: body.trim(), - createdAt: new Date().toISOString(), - parent: replyingTo?.uri ?? null, + body: input.body, + createdAt: nowIso(), + parent: input.parent, attachments: attachments as Reply["attachments"], - }); + }; + + const updatedRefs = appendRef(threadUri, newRef); + seedPageWithReply(threadUri, updatedRefs, newReply); + setBody(""); setFiles([]); setReplyingTo(null); - } catch { - alert("Could not post reply."); - } finally { - setPosting(false); - } + + const newLastPage = Math.max( + 1, + Math.ceil(updatedRefs.length / REPLIES_PER_PAGE), + ); + if (page !== newLastPage) setPage(newLastPage); + }, + onError: (err) => + alert( + `Could not post reply: ${err instanceof Error ? err.message : err}`, + ), + }); + + const deleteReplyMutation = useMutation({ + mutationFn: async (reply: Reply) => { + if (!agent) throw new Error("Not signed in"); + await deleteRecord(agent, POST, reply.rkey); + return reply; + }, + onSuccess: (reply) => { + removeRefAndReply(threadUri, reply.uri, page); + }, + onError: (err) => + alert(`Could not delete: ${err instanceof Error ? err.message : err}`), + }); + + const deleteThreadMutation = useMutation({ + mutationFn: async () => { + if (!agent) throw new Error("Not signed in"); + await deleteRecord(agent, POST, thread.rkey); + }, + onSuccess: () => navigate(`/bbs/${handle}`), + onError: (err) => + alert(`Could not delete: ${err instanceof Error ? err.message : err}`), + }); + + const moderationMutationDefaults = { onSuccess: invalidateAllBBSCaches }; + + const banMutation = useMutation({ + ...moderationMutationDefaults, + mutationFn: async (banDid: string) => { + if (!agent) throw new Error("Not signed in"); + await createBan(agent, banDid); + }, + }); + + const unbanMutation = useMutation({ + ...moderationMutationDefaults, + mutationFn: async (rkey: string) => { + if (!agent) throw new Error("Not signed in"); + await deleteRecord(agent, BAN, rkey); + }, + }); + + const hideMutation = useMutation({ + ...moderationMutationDefaults, + mutationFn: async (uri: string) => { + if (!agent) throw new Error("Not signed in"); + await createHide(agent, uri); + }, + }); + + const unhideMutation = useMutation({ + ...moderationMutationDefaults, + mutationFn: async (rkey: string) => { + if (!agent) throw new Error("Not signed in"); + await deleteRecord(agent, HIDE, rkey); + }, + }); + + // --- Handlers --- + + function onReply(event: SyntheticEvent) { + event.preventDefault(); + if (createReplyMutation.isPending) return; + createReplyMutation.mutate({ + body: body.trim(), + parent: replyingTo?.uri ?? null, + files, + }); } - async function onDeleteThread() { - if (!agent) return; + function onDeleteThread() { if (!confirm("Delete this thread?")) return; - await deleteRecord(agent, POST, thread.rkey); - navigate(`/bbs/${handle}`); + deleteThreadMutation.mutate(); } - async function onDeleteReply(reply: Reply) { - if (!agent) return; + function onDeleteReply(reply: Reply) { if (!confirm("Delete this reply?")) return; - try { - await deleteRecord(agent, POST, reply.rkey); - } catch (e: unknown) { - console.error("deleteRecord failed:", e); - alert(`Could not delete: ${e instanceof Error ? e.message : e}`); - return; - } - removeReply(reply.uri); - revalidator.revalidate(); + deleteReplyMutation.mutate(reply); } - async function onBan(banDid: string) { - if (!agent) return; + function onBan(banDid: string) { if (!confirm("Ban this user from your community?")) return; - await createBan(agent, banDid); - revalidator.revalidate(); + banMutation.mutate(banDid); } - async function onHide(uri: string) { - if (!agent) return; + function onUnban(rkey: string) { + if (!confirm("Unban this user?")) return; + unbanMutation.mutate(rkey); + } + + function onHide(uri: string) { if (!confirm("Hide this post?")) return; - await createHide(agent, uri); - revalidator.revalidate(); + hideMutation.mutate(uri); + } + + function onUnhide(rkey: string) { + if (!confirm("Unhide this post?")) return; + unhideMutation.mutate(rkey); + } + + if (threadHidden) { + return ( +

+ This thread has been hidden by the sysop. +

+ ); } return ( @@ -151,9 +252,13 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { thread={thread} userDid={user?.did} sysopDid={bbs.identity.did} + banRkey={moderation.banRkeys[thread.did] ?? null} + hideRkey={moderation.hideRkeys[thread.uri] ?? null} onDelete={onDeleteThread} onBan={() => onBan(thread.did)} + onUnban={onUnban} onHide={() => onHide(thread.uri)} + onUnhide={onUnhide} /> {totalPages > 1 && ( @@ -161,29 +266,43 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { )}
- {loadingPage ? ( -

loading...

- ) : replies.length === 0 && !user ? ( + {visibleReplies.length === 0 && !user ? (

No replies yet.

) : ( - replies.map((reply) => ( - - setReplyingTo({ uri: reply.uri, handle: reply.handle }) - } - onParentClick={ - reply.parent ? () => scrollToReply(reply.parent!) : undefined - } - onDelete={() => onDeleteReply(reply)} - onBan={() => onBan(reply.did)} - onHide={() => onHide(reply.uri)} - /> - )) + visibleReplies.map((reply) => { + const parentReply = reply.parent + ? parentReplies[reply.parent] + : null; + const parentHidden = + !!parentReply && + !isSysop && + (moderation.bannedDids.has(parentReply.did) || + moderation.hiddenUris.has(parentReply.uri)); + return ( + + setReplyingTo({ uri: reply.uri, handle: reply.handle }) + } + onParentClick={ + reply.parent ? () => scrollToReply(reply.parent!) : undefined + } + onDelete={() => onDeleteReply(reply)} + onBan={() => onBan(reply.did)} + onUnban={onUnban} + onHide={() => onHide(reply.uri)} + onUnhide={onUnhide} + /> + ); + }) )}
@@ -207,26 +326,79 @@ function ThreadPage({ loaded }: { loaded: LoaderData }) { replyingTo={replyingTo} onClearReplyTo={() => setReplyingTo(null)} submitLabel="reply" - posting={posting} + posting={createReplyMutation.isPending} /> )} ); } +// --- Cache-update helpers --- + +function getRefs(threadUri: string): BacklinkRef[] { + const key = threadRefsQuery(threadUri).queryKey; + return queryClient.getQueryData(key) ?? []; +} + +function setRefs(threadUri: string, refs: BacklinkRef[]) { + queryClient.setQueryData(threadRefsQuery(threadUri).queryKey, refs); +} + +function appendRef(threadUri: string, newRef: BacklinkRef): BacklinkRef[] { + const updated = [...getRefs(threadUri), newRef]; + setRefs(threadUri, updated); + return updated; +} + +function pageSlice(refs: BacklinkRef[], page: number): BacklinkRef[] { + const start = (page - 1) * REPLIES_PER_PAGE; + return refs.slice(start, start + REPLIES_PER_PAGE); +} + +function seedPageWithReply( + threadUri: string, + refs: BacklinkRef[], + reply: Reply, +) { + const newLastPage = Math.max(1, Math.ceil(refs.length / REPLIES_PER_PAGE)); + const pageRefs = pageSlice(refs, newLastPage); + const key = threadPageQuery(threadUri, newLastPage, pageRefs).queryKey; + queryClient.setQueryData(key, (prev) => ({ + replies: [...(prev?.replies ?? []), reply], + parentReplies: prev?.parentReplies ?? {}, + })); +} + +function removeRefAndReply( + threadUri: string, + replyUri: string, + currentPage: number, +) { + const updatedRefs = getRefs(threadUri).filter( + (ref) => refToUri(ref) !== replyUri, + ); + setRefs(threadUri, updatedRefs); + const pageRefs = pageSlice(updatedRefs, currentPage); + const key = threadPageQuery(threadUri, currentPage, pageRefs).queryKey; + queryClient.setQueryData(key, (prev) => + prev + ? { ...prev, replies: prev.replies.filter((r) => r.uri !== replyUri) } + : prev, + ); +} + function buildBreadcrumb( - bbs: BBSLoaderData["bbs"], - thread: ThreadObj, + bbs: BBS, + threadTitle: string, + boardSlug: string, handle: string, ) { - const board = bbs.site.boards.find( - (board) => board.slug === thread.boardSlug, - ); + const board = bbs.site.boards.find((b) => b.slug === boardSlug); return [ { label: bbs.site.name, to: `/bbs/${handle}` }, ...(board ? [{ label: board.name, to: `/bbs/${handle}/board/${board.slug}` }] : []), - { label: thread.title }, + { label: threadTitle }, ]; } diff --git a/web/src/router/loaders/account.ts b/web/src/router/loaders/account.ts index ce3b917..f327686 100644 --- a/web/src/router/loaders/account.ts +++ b/web/src/router/loaders/account.ts @@ -1,5 +1,25 @@ +import { redirect } from "react-router-dom"; +import { NoBBSError } from "../../lib/bbs"; +import { bbsQuery } from "../../lib/queries"; +import { queryClient } from "../../lib/queryClient"; import { requireAuth } from "./auth"; +/** Loader for /account/create — just gates the route on auth. */ export async function requireAuthLoader() { - return { user: await requireAuth() }; + await requireAuth(); + return null; +} + +/** Loader for /account/edit and /account/moderate — requires auth AND an + * existing BBS. Warms the Query cache so the page's useSuspenseQuery + * lands on fresh data with no flash. */ +export async function requireSysopBBSLoader() { + const user = await requireAuth(); + try { + await queryClient.ensureQueryData(bbsQuery(user.handle)); + } catch (error) { + if (error instanceof NoBBSError) throw redirect("/account/create"); + throw error; + } + return null; } diff --git a/web/src/router/loaders/bbs.ts b/web/src/router/loaders/bbs.ts deleted file mode 100644 index b005c0f..0000000 --- a/web/src/router/loaders/bbs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { LoaderFunctionArgs } from "react-router-dom"; -import { resolveBBS, type BBS } from "../../lib/bbs"; -import { getCurrentUser } from "../../lib/auth"; -import { fetchPins, findPinRkey } from "../../lib/pins"; - -export async function bbsLoader({ params }: LoaderFunctionArgs) { - const handle = params.handle!; - const bbs = await resolveBBS(handle); - - let pinRkey: string | null = null; - const user = getCurrentUser(); - if (user) { - const pins = await fetchPins(user.pdsUrl, user.did); - pinRkey = findPinRkey(pins, bbs.identity.did); - } - - return { handle, bbs, pinRkey }; -} - -export type BBSLoaderData = { - handle: string; - bbs: BBS; - pinRkey: string | null; -}; diff --git a/web/src/router/loaders/home.ts b/web/src/router/loaders/home.ts deleted file mode 100644 index 0382ea5..0000000 --- a/web/src/router/loaders/home.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getRecord } from "../../lib/atproto"; -import { ensureAuthReady, getCurrentUser } from "../../lib/auth"; -import { fetchActivity } from "../../lib/activity"; -import { fetchPins } from "../../lib/pins"; -import { fetchMyThreads } from "../../lib/mythreads"; -import { SITE } from "../../lib/lexicon"; - -export async function homeLoader() { - await ensureAuthReady(); - const user = getCurrentUser(); - if (!user) return { user: null }; - - let hasBBS = false; - let bbsName: string | null = null; - try { - const record = await getRecord(user.did, SITE, "self"); - hasBBS = true; - const value = record.value as { name?: string }; - bbsName = value.name ?? null; - } catch { - // no site record - } - - return { - user, - hasBBS, - bbsName, - activity: fetchActivity(user.did, user.pdsUrl), - pins: fetchPins(user.pdsUrl, user.did), - threads: fetchMyThreads(user.pdsUrl, user.did), - }; -} diff --git a/web/src/router/loaders/index.ts b/web/src/router/loaders/index.ts index fb3bc3a..7b36036 100644 --- a/web/src/router/loaders/index.ts +++ b/web/src/router/loaders/index.ts @@ -1,10 +1 @@ -export { homeLoader } from "./home"; -export { bbsLoader, type BBSLoaderData } from "./bbs"; -export { profileLoader } from "./profile"; -export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board"; -export { threadLoader, type ThreadObj } from "./thread"; -export { requireAuthLoader } from "./account"; -export type { ActivityItem } from "../../lib/activity"; -export type { PinnedBBS } from "../../lib/pins"; -export type { MyThread } from "../../lib/mythreads"; -export { sysopEditLoader, sysopModerateLoader, type HiddenInfo } from "./sysop"; +export { requireAuthLoader, requireSysopBBSLoader } from "./account"; diff --git a/web/src/router/loaders/profile.ts b/web/src/router/loaders/profile.ts deleted file mode 100644 index b53d774..0000000 --- a/web/src/router/loaders/profile.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { LoaderFunctionArgs } from "react-router-dom"; -import { fetchProfile, type Profile } from "../../lib/profile"; -import { fetchMyThreads, type MyThread } from "../../lib/mythreads"; - -export async function profileLoader({ params }: LoaderFunctionArgs) { - const handle = params.handle!; - const profile = await fetchProfile(handle); - - let threads: Promise = Promise.resolve([]); - if (profile) { - threads = fetchMyThreads(profile.pdsUrl, profile.did); - } - - return { handle, profile, threads }; -} - -export type ProfileLoaderData = { - handle: string; - profile: Profile | null; - threads: Promise; -}; diff --git a/web/src/router/loaders/thread.ts b/web/src/router/loaders/thread.ts deleted file mode 100644 index 0b4238e..0000000 --- a/web/src/router/loaders/thread.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { LoaderFunctionArgs } from "react-router-dom"; -import { resolveBBS } from "../../lib/bbs"; -import { - getRecord, - getBacklinks, - resolveIdentity, - type BacklinkRef, -} from "../../lib/atproto"; -import { POST } from "../../lib/lexicon"; -import { makeAtUri, parseAtUri } from "../../lib/util"; -import { is } from "@atcute/lexicons/validations"; -import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; -import type { XyzAtbbsPost } 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(rootUri: string): Promise { - const collected: BacklinkRef[] = []; - let cursor: string | undefined; - for (let i = 0; i < 20; i++) { - const page = await getBacklinks(rootUri, `${POST}:root`, 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, POST, tid); - const [bbs, threadRecord, author, allRefs] = await Promise.all([ - resolveBBS(handle), - getRecord(did, POST, tid), - resolveIdentity(did), - collectAllReplyRefs(threadUri), - ]); - if (!is(postSchema, threadRecord.value)) { - throw new Response("Invalid post record", { status: 404 }); - } - const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; - const boardSlug = parseAtUri(postValue.scope).rkey; - const thread: ThreadObj = { - uri: threadRecord.uri, - did, - rkey: tid, - authorHandle: author.handle, - authorPds: author.pds ?? "", - title: postValue.title ?? "", - body: postValue.body, - createdAt: postValue.createdAt, - boardSlug, - attachments: postValue.attachments as ThreadObj["attachments"], - }; - - return { handle, bbs, thread, allRefs }; -} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index eba5d80..c83210d 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -1,13 +1,6 @@ -import { - createBrowserRouter, - Outlet, - redirect, - type RouteObject, -} from "react-router-dom"; +import { createBrowserRouter, Outlet, redirect } from "react-router-dom"; import Layout from "../components/layout/Layout"; -import ErrorPage from "../components/layout/ErrorPage"; -import HydrateFallback from "../components/layout/HydrateFallback"; import Home from "../pages/Home"; import OAuthCallback from "../pages/OAuthCallback"; @@ -21,78 +14,42 @@ import SysopModerate from "../pages/SysopModerate"; import News from "../pages/News"; import NotFound from "../pages/NotFound"; -import { - homeLoader, - bbsLoader, - boardLoader, - profileLoader, - threadLoader, - requireAuthLoader, - sysopEditLoader, - sysopModerateLoader, -} from "./loaders"; +import { requireAuthLoader, requireSysopBBSLoader } from "./loaders"; -const routes: RouteObject[] = [ +export const router = createBrowserRouter([ { element: , - errorElement: , - HydrateFallback, children: [ - { path: "/", loader: homeLoader, element: }, + { path: "/", element: }, { path: "/oauth/callback", element: }, { path: "/account", loader: () => redirect("/") }, { path: "/account/create", loader: requireAuthLoader, element: , - errorElement: , }, { path: "/account/edit", - loader: sysopEditLoader, + loader: requireSysopBBSLoader, element: , - errorElement: , }, { path: "/account/moderate", - loader: sysopModerateLoader, + loader: requireSysopBBSLoader, element: , }, { path: "/bbs/:handle", - id: "bbs", - loader: bbsLoader, element: , - errorElement: , children: [ { index: true, element: }, - { - path: "board/:slug", - loader: boardLoader, - element: , - errorElement: , - }, - { - path: "thread/:did/:tid", - loader: threadLoader, - element: , - errorElement: , - }, - { - path: "news/:tid", - element: , - }, + { path: "board/:slug", element: }, + { path: "thread/:did/:tid", element: }, + { path: "news/:tid", element: }, ], }, - { - path: "/profile/:handle", - loader: profileLoader, - element: , - errorElement: , - }, + { path: "/profile/:handle", element: }, { path: "*", element: }, ], }, -]; - -export const router = createBrowserRouter(routes); +]);