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

Configure Feed

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

web: refactor to use tanstack query

+1643 -1317
+55
web/package-lock.json
··· 11 11 "@atcute/atproto": "^3.1.11", 12 12 "@atcute/client": "^4.2.1", 13 13 "@atcute/oauth-browser-client": "^3.0.0", 14 + "@tanstack/react-query": "^5.100.1", 15 + "@tanstack/react-query-devtools": "^5.100.1", 14 16 "lucide-react": "^1.8.0", 15 17 "react": "^19.2.5", 16 18 "react-dom": "^19.2.5", ··· 1334 1336 }, 1335 1337 "peerDependencies": { 1336 1338 "vite": "^5.2.0 || ^6 || ^7 || ^8" 1339 + } 1340 + }, 1341 + "node_modules/@tanstack/query-core": { 1342 + "version": "5.100.1", 1343 + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.1.tgz", 1344 + "integrity": "sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==", 1345 + "license": "MIT", 1346 + "funding": { 1347 + "type": "github", 1348 + "url": "https://github.com/sponsors/tannerlinsley" 1349 + } 1350 + }, 1351 + "node_modules/@tanstack/query-devtools": { 1352 + "version": "5.100.1", 1353 + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.1.tgz", 1354 + "integrity": "sha512-jZLV2l7XjYxXCrXHj9pj15gZuY8Te+idoSPS2hIh3+SxOd20Gn0rfUoqEw9vc+us/b16hi0/DWqpzx9O1ZsyIQ==", 1355 + "license": "MIT", 1356 + "funding": { 1357 + "type": "github", 1358 + "url": "https://github.com/sponsors/tannerlinsley" 1359 + } 1360 + }, 1361 + "node_modules/@tanstack/react-query": { 1362 + "version": "5.100.1", 1363 + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.1.tgz", 1364 + "integrity": "sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==", 1365 + "license": "MIT", 1366 + "dependencies": { 1367 + "@tanstack/query-core": "5.100.1" 1368 + }, 1369 + "funding": { 1370 + "type": "github", 1371 + "url": "https://github.com/sponsors/tannerlinsley" 1372 + }, 1373 + "peerDependencies": { 1374 + "react": "^18 || ^19" 1375 + } 1376 + }, 1377 + "node_modules/@tanstack/react-query-devtools": { 1378 + "version": "5.100.1", 1379 + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.1.tgz", 1380 + "integrity": "sha512-JuLinBUl/BlZhm0WVX83fJgE2a3YSbuEdxf3fgP+THg92hX7YfwuH5DzT35a6sL/rifZsPr0yJ9itB6jDOcdRg==", 1381 + "license": "MIT", 1382 + "dependencies": { 1383 + "@tanstack/query-devtools": "5.100.1" 1384 + }, 1385 + "funding": { 1386 + "type": "github", 1387 + "url": "https://github.com/sponsors/tannerlinsley" 1388 + }, 1389 + "peerDependencies": { 1390 + "@tanstack/react-query": "^5.100.1", 1391 + "react": "^18 || ^19" 1337 1392 } 1338 1393 }, 1339 1394 "node_modules/@tybys/wasm-util": {
+2
web/package.json
··· 12 12 "@atcute/atproto": "^3.1.11", 13 13 "@atcute/client": "^4.2.1", 14 14 "@atcute/oauth-browser-client": "^3.0.0", 15 + "@tanstack/react-query": "^5.100.1", 16 + "@tanstack/react-query-devtools": "^5.100.1", 15 17 "lucide-react": "^1.8.0", 16 18 "react": "^19.2.5", 17 19 "react-dom": "^19.2.5",
+1 -1
web/src/components/dashboard/DialBBS.tsx
··· 6 6 import { Button } from "../form/Form"; 7 7 import { useDropdown } from "../../hooks/useDropdown"; 8 8 import { useResolvedBBS } from "../../hooks/useResolvedBBS"; 9 - import type { DiscoveredBBS } from "../../hooks/useDiscovery"; 9 + import type { DiscoveredBBS } from "../../lib/discovery"; 10 10 11 11 export interface Suggestion { 12 12 to: string;
+1 -1
web/src/components/dashboard/DiscoveryList.tsx
··· 1 1 import ListLink from "../nav/ListLink"; 2 - import type { DiscoveredBBS } from "../../hooks/useDiscovery"; 2 + import type { DiscoveredBBS } from "../../lib/discovery"; 3 3 4 4 interface DiscoveryListProps { 5 5 discovered: DiscoveredBBS[];
+1 -1
web/src/components/dashboard/MyThreadList.tsx
··· 2 2 import { ChevronDown } from "lucide-react"; 3 3 import { Link } from "react-router-dom"; 4 4 import { parseAtUri, formatFullDate, relativeDate } from "../../lib/util"; 5 - import type { MyThread } from "../../router/loaders"; 5 + import type { MyThread } from "../../lib/mythreads"; 6 6 7 7 const PAGE_SIZE = 10; 8 8
+1 -1
web/src/components/dashboard/PinnedList.tsx
··· 1 1 import { useState } from "react"; 2 2 import { ChevronDown } from "lucide-react"; 3 3 import ListLink from "../nav/ListLink"; 4 - import type { PinnedBBS } from "../../router/loaders"; 4 + import type { PinnedBBS } from "../../lib/pins"; 5 5 6 6 const PAGE_SIZE = 5; 7 7
+29
web/src/components/layout/ErrorBoundary.tsx
··· 1 + import { Component, type ReactNode } from "react"; 2 + import ErrorPage from "./ErrorPage"; 3 + 4 + interface Props { 5 + children: ReactNode; 6 + } 7 + 8 + interface State { 9 + error: unknown; 10 + } 11 + 12 + export default class ErrorBoundary extends Component<Props, State> { 13 + state: State = { error: null }; 14 + 15 + static getDerivedStateFromError(error: unknown): State { 16 + return { error }; 17 + } 18 + 19 + componentDidUpdate(prev: Props) { 20 + if (prev.children !== this.props.children && this.state.error) { 21 + this.setState({ error: null }); 22 + } 23 + } 24 + 25 + render() { 26 + if (this.state.error) return <ErrorPage error={this.state.error} />; 27 + return this.props.children; 28 + } 29 + }
+7 -15
web/src/components/layout/ErrorPage.tsx
··· 1 - import { isRouteErrorResponse, useRouteError } from "react-router-dom"; 2 - import { BBSNotFoundError, NoBBSError, NetworkError } from "../../lib/bbs"; 1 + import { BBSNotFoundError, NoBBSError } from "../../lib/bbs"; 3 2 import { useAuth } from "../../lib/auth"; 4 3 import { ActionLink } from "../nav/ActionButton"; 5 4 6 - export default function ErrorPage() { 7 - const error = useRouteError(); 5 + interface ErrorPageProps { 6 + error: unknown; 7 + } 8 + 9 + export default function ErrorPage({ error }: ErrorPageProps) { 8 10 const { user } = useAuth(); 9 11 10 12 let title = "Something went wrong."; 11 13 let detail: string | null = null; 12 - let action: { to: string; label: string } = { 13 - to: "/", 14 - label: "← back to home", 15 - }; 14 + let action = { to: "/", label: "← back to home" }; 16 15 17 16 if (error instanceof BBSNotFoundError) { 18 17 title = "Community not found."; ··· 26 25 "This account isn't running a community yet. Is this you? Log in to start one."; 27 26 action = { to: "/?login=1", label: "log in" }; 28 27 } 29 - } else if (error instanceof NetworkError) { 30 - title = "Couldn't reach the network."; 31 - detail = "Try again in a moment."; 32 - } else if (isRouteErrorResponse(error)) { 33 - if (error.status === 404) title = "Not found."; 34 - else title = error.statusText || `Error ${error.status}`; 35 - if (typeof error.data === "string") detail = error.data; 36 28 } else if (error instanceof Error) { 37 29 detail = error.message; 38 30 }
+16 -6
web/src/components/layout/Layout.tsx
··· 1 - import { Outlet, useNavigation } from "react-router-dom"; 1 + import { Suspense } from "react"; 2 + import { Outlet, useLocation, useNavigation } from "react-router-dom"; 3 + import { useIsFetching } from "@tanstack/react-query"; 2 4 import Header from "./Header"; 3 5 import MobileBackButton from "./MobileBackButton"; 4 6 import Footer from "./Footer"; 7 + import ErrorBoundary from "./ErrorBoundary"; 5 8 import LoginModal from "../auth/LoginModal"; 6 9 import { LoginModalProvider } from "../../lib/loginModal"; 7 - import { useRevalidateOnFocus } from "../../hooks/useRevalidateOnFocus"; 8 10 9 11 export default function Layout() { 10 - const isLoading = useNavigation().state === "loading"; 11 - useRevalidateOnFocus(); 12 + const routeLoading = useNavigation().state === "loading"; 13 + const queriesLoading = useIsFetching() > 0; 14 + const showProgress = routeLoading || queriesLoading; 15 + // Remount ErrorBoundary + Suspense on navigation so a fresh page doesn't 16 + // inherit the previous page's error or fallback state. 17 + const routeKey = useLocation().pathname; 12 18 13 19 return ( 14 20 <LoginModalProvider> 15 21 <div className="flex flex-col h-dvh"> 16 - {isLoading && ( 22 + {showProgress && ( 17 23 <div 18 24 className="fixed top-0 left-0 right-0 h-0.5 bg-neutral-400 z-50" 19 25 style={{ animation: "atbbs-progress 1.5s ease-out infinite" }} ··· 22 28 <Header /> 23 29 <main className="max-w-2xl mx-auto px-4 py-8 flex-1 w-full"> 24 30 <MobileBackButton /> 25 - <Outlet /> 31 + <ErrorBoundary key={routeKey}> 32 + <Suspense fallback={null}> 33 + <Outlet /> 34 + </Suspense> 35 + </ErrorBoundary> 26 36 </main> 27 37 <Footer /> 28 38 <LoginModal />
+1 -1
web/src/components/nav/ThreadLink.tsx
··· 1 1 import { Link } from "react-router-dom"; 2 2 import Avatar from "../Avatar"; 3 - import type { Participant } from "../../router/loaders/board"; 3 + import type { Participant } from "../../lib/boardThreads"; 4 4 5 5 const COL_POSTERS = "w-20"; 6 6 const COL_REPLIES = "w-14";
+22
web/src/components/post/ModerationBadge.tsx
··· 1 + import { Ban, EyeOff } from "lucide-react"; 2 + 3 + interface ModerationBadgeProps { 4 + isHidden: boolean; 5 + isBannedAuthor: boolean; 6 + } 7 + 8 + export default function ModerationBadge({ 9 + isHidden, 10 + isBannedAuthor, 11 + }: ModerationBadgeProps) { 12 + if (!isHidden && !isBannedAuthor) return null; 13 + return ( 14 + <span 15 + title="Only visible to you as sysop." 16 + className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wide text-neutral-500 border border-neutral-800 rounded px-1.5 py-0.5 mb-2" 17 + > 18 + {isHidden ? <EyeOff size={10} /> : <Ban size={10} />} 19 + {isHidden ? "hidden" : "author banned"} 20 + </span> 21 + ); 22 + }
+30 -4
web/src/components/post/PostActions.tsx
··· 1 1 import { useRef, useState, useEffect } from "react"; 2 - import { Reply, MoreHorizontal, Trash2, Ban, EyeOff } from "lucide-react"; 2 + import { Reply, MoreHorizontal, Trash2, Ban, EyeOff, Eye } from "lucide-react"; 3 3 4 4 interface PostActionsProps { 5 5 isAuthor: boolean; 6 6 isSysop: boolean; 7 + banRkey?: string | null; 8 + hideRkey?: string | null; 7 9 onDelete?: () => void; 8 10 onBan?: () => void; 11 + onUnban?: (rkey: string) => void; 9 12 onHide?: () => void; 13 + onUnhide?: (rkey: string) => void; 10 14 onReplyTo?: () => void; 11 15 } 12 16 13 17 export default function PostActions({ 14 18 isAuthor, 15 19 isSysop, 20 + banRkey, 21 + hideRkey, 16 22 onDelete, 17 23 onBan, 24 + onUnban, 18 25 onHide, 26 + onUnhide, 19 27 onReplyTo, 20 28 }: PostActionsProps) { 21 29 const [open, setOpen] = useState(false); ··· 33 41 }, [open]); 34 42 35 43 const canDelete = isAuthor && !!onDelete; 36 - const canBan = isSysop && !isAuthor && !!onBan; 37 - const canHide = isSysop && !!onHide; 38 - const hasModActions = canDelete || canBan || canHide; 44 + const canBan = isSysop && !isAuthor && !!onBan && !banRkey; 45 + const canUnban = isSysop && !!onUnban && !!banRkey; 46 + const canHide = isSysop && !!onHide && !hideRkey; 47 + const canUnhide = isSysop && !!onUnhide && !!hideRkey; 48 + const hasModActions = canDelete || canBan || canUnban || canHide || canUnhide; 39 49 40 50 if (!onReplyTo && !hasModActions) return null; 41 51 ··· 79 89 <Ban size={12} /> ban 80 90 </button> 81 91 )} 92 + {canUnban && ( 93 + <button 94 + onClick={() => select(() => onUnban!(banRkey!))} 95 + className={menuItem} 96 + > 97 + <Ban size={12} /> unban 98 + </button> 99 + )} 82 100 {canHide && ( 83 101 <button onClick={() => select(onHide)} className={dangerItem}> 84 102 <EyeOff size={12} /> hide 103 + </button> 104 + )} 105 + {canUnhide && ( 106 + <button 107 + onClick={() => select(() => onUnhide!(hideRkey!))} 108 + className={menuItem} 109 + > 110 + <Eye size={12} /> unhide 85 111 </button> 86 112 )} 87 113 </div>
+23 -5
web/src/components/post/ReplyCard.tsx
··· 1 + import { truncate } from "../../lib/util"; 1 2 import AttachmentLink from "./AttachmentLink"; 3 + import ModerationBadge from "./ModerationBadge"; 2 4 import PostActions from "./PostActions"; 3 5 import PostBody from "./PostBody"; 4 6 import PostMeta from "./PostMeta"; ··· 20 22 userDid: string; 21 23 sysopDid: string; 22 24 parentPost?: Reply; 25 + banRkey?: string | null; 26 + hideRkey?: string | null; 23 27 onReplyTo: () => void; 24 28 onParentClick?: () => void; 25 29 onDelete: () => void; 26 30 onBan: () => void; 31 + onUnban: (rkey: string) => void; 27 32 onHide: () => void; 33 + onUnhide: (rkey: string) => void; 28 34 } 29 35 30 36 export default function ReplyCard({ ··· 32 38 userDid, 33 39 sysopDid, 34 40 parentPost, 41 + banRkey, 42 + hideRkey, 35 43 onReplyTo, 36 44 onParentClick, 37 45 onDelete, 38 46 onBan, 47 + onUnban, 39 48 onHide, 49 + onUnhide, 40 50 }: ReplyCardProps) { 41 51 const isAuthor = userDid === reply.did; 42 52 const isSysop = userDid === sysopDid; 53 + const isModerated = !!banRkey || !!hideRkey; 43 54 44 55 return ( 45 56 <div 46 57 id={`reply-${reply.rkey}`} 47 - className="reply-card border border-neutral-800/50 rounded p-4" 58 + className={`reply-card border rounded p-4 ${ 59 + isModerated 60 + ? "border-neutral-800 bg-neutral-900/30 opacity-60" 61 + : "border-neutral-800/50" 62 + }`} 48 63 > 49 64 <div className="flex items-baseline justify-between mb-2"> 50 65 <PostMeta handle={reply.handle} createdAt={reply.createdAt} /> 51 66 <PostActions 52 67 isAuthor={isAuthor} 53 68 isSysop={isSysop} 69 + banRkey={banRkey} 70 + hideRkey={hideRkey} 54 71 onReplyTo={userDid ? onReplyTo : undefined} 55 72 onDelete={onDelete} 56 73 onBan={onBan} 74 + onUnban={onUnban} 57 75 onHide={onHide} 76 + onUnhide={onUnhide} 58 77 /> 59 78 </div> 60 79 80 + <ModerationBadge isHidden={!!hideRkey} isBannedAuthor={!!banRkey} /> 81 + 61 82 {parentPost && ( 62 83 <button 63 84 type="button" ··· 65 86 className="block w-full text-left border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-400 hover:border-neutral-500 cursor-pointer" 66 87 > 67 88 <span className="text-neutral-400">{parentPost.handle}:</span>{" "} 68 - <PostBody> 69 - {parentPost.body.substring(0, 200) + 70 - (parentPost.body.length > 200 ? "..." : "")} 71 - </PostBody> 89 + <PostBody>{truncate(parentPost.body, 200)}</PostBody> 72 90 </button> 73 91 )} 74 92
+24 -5
web/src/components/post/ThreadCard.tsx
··· 1 - import type { ThreadObj } from "../../router/loaders"; 1 + import type { ThreadRoot } from "../../lib/thread"; 2 2 import AttachmentLink from "./AttachmentLink"; 3 + import ModerationBadge from "./ModerationBadge"; 3 4 import PostActions from "./PostActions"; 4 5 import PostBody from "./PostBody"; 5 6 import PostMeta from "./PostMeta"; 6 7 7 - interface ThreadHeaderProps { 8 - thread: ThreadObj; 8 + interface ThreadCardProps { 9 + thread: ThreadRoot; 9 10 userDid?: string; 10 11 sysopDid: string; 12 + banRkey?: string | null; 13 + hideRkey?: string | null; 11 14 onDelete: () => void; 12 15 onBan: () => void; 16 + onUnban: (rkey: string) => void; 13 17 onHide: () => void; 18 + onUnhide: (rkey: string) => void; 14 19 } 15 20 16 21 export default function ThreadCard({ 17 22 thread, 18 23 userDid, 19 24 sysopDid, 25 + banRkey, 26 + hideRkey, 20 27 onDelete, 21 28 onBan, 29 + onUnban, 22 30 onHide, 23 - }: ThreadHeaderProps) { 31 + onUnhide, 32 + }: ThreadCardProps) { 24 33 const isAuthor = !!(userDid && userDid === thread.did); 25 34 const isSysop = !!(userDid && userDid === sysopDid); 35 + const isModerated = !!banRkey || !!hideRkey; 26 36 27 37 return ( 28 - <article className="reply-card bg-neutral-900 border border-neutral-800 rounded p-4 mb-4"> 38 + <article 39 + className={`reply-card bg-neutral-900 border rounded p-4 mb-4 ${ 40 + isModerated ? "border-neutral-800 opacity-60" : "border-neutral-800" 41 + }`} 42 + > 29 43 <div className="flex items-baseline justify-between mb-3"> 30 44 <PostMeta handle={thread.authorHandle} createdAt={thread.createdAt} /> 31 45 <PostActions 32 46 isAuthor={isAuthor} 33 47 isSysop={isSysop} 48 + banRkey={banRkey} 49 + hideRkey={hideRkey} 34 50 onDelete={onDelete} 35 51 onBan={onBan} 52 + onUnban={onUnban} 36 53 onHide={onHide} 54 + onUnhide={onUnhide} 37 55 /> 38 56 </div> 57 + <ModerationBadge isHidden={!!hideRkey} isBannedAuthor={!!banRkey} /> 39 58 <h1 className="text-lg text-neutral-200 font-bold mb-3"> 40 59 {thread.title} 41 60 </h1>
-78
web/src/hooks/useDiscovery.ts
··· 1 - /** Fetch discovered BBSes from the Lightrail API, cached in memory. */ 2 - 3 - import { useEffect, useState } from "react"; 4 - import { TTLCache } from "../lib/cache"; 5 - import { getAvatars, getRecord, resolveIdentitiesBatch } from "../lib/atproto"; 6 - import { SITE } from "../lib/lexicon"; 7 - import { SERVICES } from "../lib/shared"; 8 - import { is } from "@atcute/lexicons/validations"; 9 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 10 - import type { XyzAtbbsSite } from "../lexicons"; 11 - 12 - interface LightrailRepo { 13 - did: string; 14 - } 15 - 16 - export interface DiscoveredBBS { 17 - did: string; 18 - handle: string; 19 - name: string; 20 - description: string; 21 - avatar?: string; 22 - } 23 - 24 - const discoveryCache = new TTLCache<string, DiscoveredBBS[]>(5 * 60 * 1000); 25 - 26 - export function useDiscovery(): DiscoveredBBS[] { 27 - const [discovered, setDiscovered] = useState<DiscoveredBBS[]>([]); 28 - 29 - useEffect(() => { 30 - const cached = discoveryCache.get("all"); 31 - if (cached) { 32 - setDiscovered(cached); 33 - return; 34 - } 35 - (async () => { 36 - try { 37 - const response = await fetch( 38 - `${SERVICES.lightrail}/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`, 39 - ); 40 - const data = (await response.json()) as { repos: LightrailRepo[] }; 41 - if (!data.repos.length) return; 42 - 43 - const shuffled = data.repos.sort(() => Math.random() - 0.5); 44 - const identities = await resolveIdentitiesBatch( 45 - shuffled.map((repo) => repo.did), 46 - ); 47 - 48 - const items: DiscoveredBBS[] = []; 49 - for (const repo of shuffled) { 50 - if (!(repo.did in identities)) continue; 51 - try { 52 - const siteRecord = await getRecord(repo.did, SITE, "self"); 53 - if (!is(siteSchema, siteRecord.value)) continue; 54 - const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; 55 - items.push({ 56 - did: repo.did, 57 - handle: identities[repo.did].handle, 58 - name: siteValue.name || identities[repo.did].handle, 59 - description: siteValue.description || "", 60 - }); 61 - } catch { 62 - continue; 63 - } 64 - } 65 - 66 - const avatars = await getAvatars(items.map((item) => item.did)); 67 - for (const item of items) { 68 - item.avatar = avatars[item.did]; 69 - } 70 - 71 - discoveryCache.set("all", items); 72 - setDiscovered(items); 73 - } catch {} 74 - })(); 75 - }, []); 76 - 77 - return discovered; 78 - }
-32
web/src/hooks/useRevalidateOnFocus.ts
··· 1 - /** Revalidate active route loaders when the tab regains focus/visibility or 2 - * the browser reconnects. Throttled so rapid focus changes don't storm. */ 3 - 4 - import { useEffect, useRef } from "react"; 5 - import { useRevalidator } from "react-router-dom"; 6 - 7 - const MIN_INTERVAL_MS = 30_000; 8 - 9 - export function useRevalidateOnFocus() { 10 - const revalidator = useRevalidator(); 11 - const lastAt = useRef(0); 12 - 13 - useEffect(() => { 14 - function maybeRevalidate() { 15 - if (document.hidden) return; 16 - if (revalidator.state !== "idle") return; 17 - const now = Date.now(); 18 - if (now - lastAt.current < MIN_INTERVAL_MS) return; 19 - lastAt.current = now; 20 - revalidator.revalidate(); 21 - } 22 - 23 - window.addEventListener("focus", maybeRevalidate); 24 - window.addEventListener("online", maybeRevalidate); 25 - document.addEventListener("visibilitychange", maybeRevalidate); 26 - return () => { 27 - window.removeEventListener("focus", maybeRevalidate); 28 - window.removeEventListener("online", maybeRevalidate); 29 - document.removeEventListener("visibilitychange", maybeRevalidate); 30 - }; 31 - }, [revalidator]); 32 - }
+60 -266
web/src/hooks/useThreadReplies.ts
··· 1 - /** Manages pagination, record fetching, and optimistic updates for a 2 - * thread's reply list. */ 1 + /** Thread-page data fetcher: refs from Constellation, hydrated page replies, 2 + * plus pagination + scroll-to-reply helpers. Optimistic mutations are in 3 + * Thread.tsx and update the same query caches via setQueryData. */ 3 4 4 - import { useCallback, useEffect, useMemo, useState } from "react"; 5 + import { useEffect } from "react"; 5 6 import { useSearchParams } from "react-router-dom"; 6 - import { getRecordsBatch, resolveIdentitiesBatch } from "../lib/atproto"; 7 + import { useSuspenseQuery } from "@tanstack/react-query"; 8 + import { threadPageQuery, threadRefsQuery } from "../lib/queries"; 7 9 import { parseAtUri } from "../lib/util"; 8 - import type { BBS } from "../lib/bbs"; 9 - import type { Reply } from "../components/post/ReplyCard"; 10 10 import { 11 11 REPLIES_PER_PAGE, 12 - type BacklinkRef, 13 - refToUri, 12 + clampPage, 13 + pageForRkey, 14 14 pageForReply, 15 15 rkeyFromHash, 16 - pageForRkey, 17 - clampPage, 18 - recordToReply, 19 16 } from "../lib/replies"; 20 17 21 - interface ThreadLoaderData { 22 - bbs: BBS; 23 - allRefs: BacklinkRef[]; 24 - } 25 - 26 - export function useThreadReplies(loaded: ThreadLoaderData) { 27 - const { bbs, allRefs } = loaded; 18 + export function useThreadReplies(threadUri: string) { 28 19 const [params, setParams] = useSearchParams(); 29 20 30 - // --- Optimistic state --- 31 - // 32 - // PDS writes land instantly but Constellation lags behind; track 33 - // in-flight mutations here so the UI stays responsive. 34 - 35 - const [pendingAdds, setPendingAdds] = useState< 36 - Record<string, { ref: BacklinkRef; item: Reply }> 37 - >({}); 38 - const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(new Set()); 39 - 40 - // Combine the loader's refs with our local overlay. The prune effect 41 - // below keeps pendingAdds from growing stale. 42 - const loaderFingerprint = allRefs.map((r) => r.rkey).join("|"); 43 - 44 - const refs = useMemo(() => { 45 - const base = allRefs.filter((r) => !pendingDeletes.has(refToUri(r))); 46 - const adds = Object.values(pendingAdds).map((p) => p.ref); 47 - return [...base, ...adds]; 48 - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on 49 - // loaderFingerprint (string) rather than allRefs (unstable reference) 50 - }, [loaderFingerprint, pendingAdds, pendingDeletes]); 51 - 52 - // When the loader refreshes and allRefs now includes a reply we added 53 - // optimistically, drop it from pendingAdds so the loader is the source 54 - // of truth going forward. 55 - useEffect(() => { 56 - setPendingAdds((prev) => { 57 - const loaderUris = new Set(allRefs.map(refToUri)); 58 - let changed = false; 59 - const next: typeof prev = {}; 60 - for (const [uri, entry] of Object.entries(prev)) { 61 - if (loaderUris.has(uri)) { 62 - changed = true; 63 - } else { 64 - next[uri] = entry; 65 - } 66 - } 67 - return changed ? next : prev; 68 - }); 69 - // eslint-disable-next-line react-hooks/exhaustive-deps -- same reason 70 - }, [loaderFingerprint]); 71 - 21 + const { data: refs } = useSuspenseQuery(threadRefsQuery(threadUri)); 72 22 const totalPages = Math.max(1, Math.ceil(refs.length / REPLIES_PER_PAGE)); 73 23 74 - // --- Pagination --- 75 - 76 - // Determine initial scroll target from ?reply= or #reply- 77 - const initialReplyParam = params.get("reply"); 78 - const initialHashRkey = rkeyFromHash(); 79 - const initialScrollRkey = initialReplyParam 80 - ? parseAtUri(initialReplyParam).rkey 81 - : initialHashRkey; 82 - 83 - const [page, setPage] = useState<number>(() => { 84 - const fromUrl = parseInt(params.get("page") ?? "1", 10); 85 - const fromReply = pageForReply(allRefs, initialReplyParam); 86 - const fromHash = pageForRkey(allRefs, initialHashRkey); 87 - return clampPage(fromHash ?? fromReply ?? fromUrl, allRefs.length); 88 - }); 89 - 90 - const [initialScrollDone, setInitialScrollDone] = 91 - useState(!initialScrollRkey); 92 - 93 - // Keep the URL in sync when the user changes page (e.g. via PageNav). 94 - useEffect(() => { 95 - const urlPage = parseInt(params.get("page") ?? "1", 10); 96 - if (urlPage === page) return; 97 - setParams((prev) => { 98 - const next = new URLSearchParams(prev); 99 - if (page === 1) next.delete("page"); 100 - else next.set("page", String(page)); 101 - return next; 102 - }); 103 - // eslint-disable-next-line react-hooks/exhaustive-deps -- only when 104 - // `page` changes, not when params object identity changes 105 - }, [page]); 106 - 107 - // Keep the page in sync when the user hits Back/Forward. 108 - const urlPage = parseInt(params.get("page") ?? "1", 10); 109 - useEffect(() => { 110 - if (urlPage !== page) setPage(urlPage); 111 - // eslint-disable-next-line react-hooks/exhaustive-deps 112 - }, [urlPage]); 113 - 114 - // --- Hydration --- 115 - 116 - const [replies, setReplies] = useState<Reply[]>([]); 117 - const [loading, setLoading] = useState(true); 118 - 119 - // All replies we've ever seen — accumulates across page changes so parent 120 - // previews and scroll targets always resolve, even for off-page replies. 121 - const [replyCache, setReplyCache] = useState<Record<string, Reply>>({}); 122 - 123 - // Pending scroll target — set when navigating to a reply on another page. 124 - // Cleared once the scroll completes. 125 - const [pendingScrollRkey, setPendingScrollRkey] = useState<string | null>( 126 - null, 127 - ); 128 - 129 - const fetchVisiblePage = useCallback( 130 - async (currentRefs: BacklinkRef[], currentPage: number) => { 131 - setLoading(true); 132 - 133 - const start = (currentPage - 1) * REPLIES_PER_PAGE; 134 - const slice = currentRefs.slice(start, start + REPLIES_PER_PAGE); 135 - 136 - if (!slice.length) { 137 - setReplies([]); 138 - setLoading(false); 139 - return; 140 - } 141 - 142 - // Fetch records from Slingshot. 143 - const records = await getRecordsBatch(slice); 144 - 145 - const visible = records; 146 - 147 - // Resolve author handles and build Reply objects. 148 - const dids = visible.map((r) => parseAtUri(r.uri).did); 149 - const authors = await resolveIdentitiesBatch(dids); 150 - const items: Reply[] = visible 151 - .map((r) => recordToReply(r, authors)) 152 - .filter((r): r is Reply => r !== null); 153 - 154 - // Merge in optimistic adds that Slingshot hasn't caught up to yet. 155 - const fetchedUris = new Set(items.map((i) => i.uri)); 156 - const sliceUris = new Set(slice.map(refToUri)); 157 - for (const [uri, pending] of Object.entries(pendingAdds)) { 158 - if (!fetchedUris.has(uri) && sliceUris.has(uri)) { 159 - items.push(pending.item); 160 - } 161 - } 162 - 163 - // Drop just-deleted replies; Constellation and Slingshot can lag 164 - // behind the PDS and briefly return stale copies. 165 - const visibleItems = items.filter( 166 - (item) => !pendingDeletes.has(item.uri), 167 - ); 24 + // --- Page derived from URL, clamped to the available range --- 168 25 169 - visibleItems.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); 170 - setReplies(visibleItems); 171 - setLoading(false); 26 + const requestedPage = parseInt(params.get("page") ?? "1", 10); 27 + const replyParam = params.get("reply"); 28 + const hashRkey = rkeyFromHash(); 29 + const initialPage = 30 + pageForRkey(refs, hashRkey) ?? 31 + pageForReply(refs, replyParam) ?? 32 + requestedPage; 33 + const page = clampPage(initialPage, refs.length); 172 34 173 - // Add current page replies to the cache 174 - const newCache: Record<string, Reply> = {}; 175 - for (const item of visibleItems) newCache[item.uri] = item; 35 + const pageStart = (page - 1) * REPLIES_PER_PAGE; 36 + const pageRefs = refs.slice(pageStart, pageStart + REPLIES_PER_PAGE); 176 37 177 - // Fetch any parent replies not already known 178 - const missingParents = visibleItems 179 - .filter((item) => item.parent && !newCache[item.parent!]) 180 - .map((item) => item.parent!) 181 - .filter((uri) => !replyCache[uri]); 182 - if (missingParents.length) { 183 - const parentRefs = [...new Set(missingParents)].map((uri) => 184 - parseAtUri(uri), 185 - ); 186 - const parentRecords = await getRecordsBatch(parentRefs); 187 - const parentDids = parentRecords.map( 188 - (record) => parseAtUri(record.uri).did, 189 - ); 190 - const parentAuthors = await resolveIdentitiesBatch(parentDids); 191 - for (const record of parentRecords) { 192 - const reply = recordToReply(record, parentAuthors); 193 - if (reply) newCache[reply.uri] = reply; 194 - } 195 - } 196 - 197 - setReplyCache((prev) => ({ ...prev, ...newCache })); 198 - }, 199 - // eslint-disable-next-line react-hooks/exhaustive-deps -- pendingAdds 200 - // and pendingDeletes are included so the merge/filter steps always see 201 - // the latest optimistic set 202 - [bbs, pendingAdds, pendingDeletes], 38 + const { data: pageData } = useSuspenseQuery( 39 + threadPageQuery(threadUri, page, pageRefs), 203 40 ); 41 + const { replies, parentReplies } = pageData; 204 42 205 - // Re-fetch whenever the visible page or the underlying ref list changes. 206 - const refsLength = refs.length; 207 - useEffect(() => { 208 - fetchVisiblePage(refs, page); 209 - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on 210 - // stable scalars, not the refs array reference or callback identity 211 - }, [refsLength, page, loaderFingerprint]); 43 + // --- Keep URL in sync when the derived page differs from what's in it --- 212 44 213 - // Scroll to a reply after a cross-page navigation completes. 214 45 useEffect(() => { 215 - if (!pendingScrollRkey) return; 216 - const id = `reply-${pendingScrollRkey}`; 217 - const el = document.getElementById(id); 218 - if (el) { 219 - el.scrollIntoView({ behavior: "smooth" }); 220 - setPendingScrollRkey(null); 221 - } 222 - }, [pendingScrollRkey, replies]); 46 + const fromUrl = parseInt(params.get("page") ?? "1", 10); 47 + if (fromUrl === page) return; 48 + setParams((prev) => writePageParam(prev, page), { replace: true }); 49 + // eslint-disable-next-line react-hooks/exhaustive-deps -- params identity churns 50 + }, [page]); 223 51 224 - // Scroll to the initial target after the first load. 225 - useEffect(() => { 226 - if (initialScrollDone || loading || !initialScrollRkey) return; 227 - setInitialScrollDone(true); 228 - const el = document.getElementById(`reply-${initialScrollRkey}`); 229 - if (el) { 230 - el.scrollIntoView({ behavior: "instant" }); 231 - } 232 - // eslint-disable-next-line react-hooks/exhaustive-deps 233 - }, [loading, replies]); 52 + // --- Navigation helpers --- 234 53 235 - // --- Public actions --- 54 + function setPage(next: number) { 55 + const clamped = clampPage(next, refs.length); 56 + setParams((prev) => writePageParam(prev, clamped)); 57 + } 236 58 237 - const addOptimisticReply = useCallback( 238 - (item: Reply) => { 239 - const ref = parseAtUri(item.uri); 240 - setPendingAdds((prev) => ({ ...prev, [item.uri]: { ref, item } })); 241 - 242 - const newTotalPages = Math.max( 243 - 1, 244 - Math.ceil((refs.length + 1) / REPLIES_PER_PAGE), 245 - ); 246 - if (page === newTotalPages) { 247 - // Already on the last page — just append. 248 - setReplies((prev) => 249 - [...prev, item].sort((a, b) => 250 - a.createdAt.localeCompare(b.createdAt), 251 - ), 252 - ); 253 - } else { 254 - // Jump to the (new) last page so the reply is visible. 255 - setPage(newTotalPages); 256 - } 257 - }, 258 - [refs.length, page], 259 - ); 260 - 261 - const removeReply = useCallback((uri: string) => { 262 - setPendingDeletes((prev) => new Set(prev).add(uri)); 263 - setReplies((prev) => prev.filter((r) => r.uri !== uri)); 264 - }, []); 265 - 266 - const scrollToReply = useCallback( 267 - (uri: string) => { 268 - const { rkey } = parseAtUri(uri); 269 - // If already on screen, just scroll 270 - const el = document.getElementById(`reply-${rkey}`); 271 - if (el) { 272 - el.scrollIntoView({ behavior: "smooth" }); 273 - return; 274 - } 275 - // Find the page and navigate — the effect will scroll once loaded 276 - const idx = refs.findIndex((r) => refToUri(r) === uri); 277 - if (idx >= 0) { 278 - const targetPage = Math.floor(idx / REPLIES_PER_PAGE) + 1; 279 - setPendingScrollRkey(rkey); 280 - setPage(targetPage); 281 - } 282 - }, 283 - [refs], 284 - ); 59 + function scrollToReply(uri: string) { 60 + const { rkey } = parseAtUri(uri); 61 + const onScreen = document.getElementById(`reply-${rkey}`); 62 + if (onScreen) { 63 + onScreen.scrollIntoView({ behavior: "smooth" }); 64 + return; 65 + } 66 + const targetPage = pageForRkey(refs, rkey); 67 + if (targetPage === null) return; 68 + setParams((prev) => { 69 + const next = writePageParam(prev, targetPage); 70 + next.set("reply", uri); 71 + return next; 72 + }); 73 + } 285 74 286 75 return { 287 76 page, 288 77 setPage, 289 78 totalPages, 79 + refs, 290 80 replies, 291 - loading, 292 - refs, 293 - replyCache, 81 + parentReplies, 294 82 scrollToReply, 295 - addOptimisticReply, 296 - removeReply, 297 83 }; 298 84 } 85 + 86 + function writePageParam(prev: URLSearchParams, page: number) { 87 + const next = new URLSearchParams(prev); 88 + if (page === 1) next.delete("page"); 89 + else next.set("page", String(page)); 90 + next.delete("reply"); 91 + return next; 92 + }
+132 -96
web/src/lib/atproto.ts
··· 1 1 /** Read-side wrappers for Slingshot and Constellation (no auth needed). */ 2 2 3 - import { TTLCache } from "./cache"; 3 + import { queryClient, STALE_SLOW } from "./queryClient"; 4 4 import { SERVICES } from "./shared"; 5 5 import { parseAtUri } from "./util"; 6 6 7 7 const SLINGSHOT = SERVICES.slingshot; 8 8 const CONSTELLATION = SERVICES.constellation; 9 + 10 + const BSKY_CDN = "https://cdn.bsky.app"; 11 + const BSKY_PROFILE = "app.bsky.actor.profile"; 12 + 13 + // --- Types --- 9 14 10 15 export interface MiniDoc { 11 16 did: string; ··· 36 41 cursor?: string; 37 42 } 38 43 44 + // --- Low-level JSON fetcher --- 45 + 39 46 async function fetchJson<T>(url: string): Promise<T> { 40 47 const resp = await fetch(url); 41 48 if (!resp.ok) throw new Error(`${resp.status} ${url}`); 42 49 return resp.json() as Promise<T>; 43 50 } 44 51 45 - const identityCache = new TTLCache<string, MiniDoc>(5 * 60 * 1000); 46 - // `null` means we've looked and there's no avatar — cache that too so we don't refetch. 47 - const avatarCache = new TTLCache<string, string | null>(5 * 60 * 1000); 48 - const backlinkCountCache = new TTLCache<string, number>(60 * 1000); 52 + // --- Records --- 49 53 50 - const BSKY_CDN = "https://cdn.bsky.app"; 51 - const BSKY_PROFILE = "app.bsky.actor.profile"; 52 - 53 - function extractAvatarCid(value: Record<string, unknown>): string | null { 54 - const avatar = value.avatar as { ref?: { $link?: string } } | undefined; 55 - return avatar?.ref?.$link ?? null; 56 - } 57 - 58 - export async function getAvatar(did: string): Promise<string | undefined> { 59 - const cached = avatarCache.get(did); 60 - if (cached !== undefined) return cached ?? undefined; 61 - try { 62 - const record = await getRecord(did, BSKY_PROFILE, "self"); 63 - const cid = extractAvatarCid(record.value); 64 - const url = cid ? `${BSKY_CDN}/img/avatar/plain/${did}/${cid}` : null; 65 - avatarCache.set(did, url); 66 - return url ?? undefined; 67 - } catch { 68 - avatarCache.set(did, null); 69 - return undefined; 70 - } 71 - } 72 - 73 - export async function getAvatars( 74 - dids: string[], 75 - ): Promise<Record<string, string>> { 76 - const unique = [...new Set(dids)]; 77 - const urls = await Promise.all(unique.map(getAvatar)); 78 - const map: Record<string, string> = {}; 79 - unique.forEach((did, index) => { 80 - const url = urls[index]; 81 - if (url) map[did] = url; 82 - }); 83 - return map; 84 - } 85 - 86 - export async function resolveIdentity(identifier: string): Promise<MiniDoc> { 87 - const cached = identityCache.get(identifier); 88 - if (cached) return cached; 89 - 90 - const doc = await fetchJson<MiniDoc>( 91 - `${SLINGSHOT}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, 54 + async function fetchRecord( 55 + did: string, 56 + collection: string, 57 + rkey: string, 58 + ): Promise<ATRecord> { 59 + return fetchJson<ATRecord>( 60 + `${SLINGSHOT}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, 92 61 ); 93 - identityCache.set(identifier, doc); 94 - identityCache.set(doc.did, doc); 95 - return doc; 96 - } 97 - 98 - export async function resolveIdentitiesBatch( 99 - dids: string[], 100 - ): Promise<Record<string, MiniDoc>> { 101 - const unique = [...new Set(dids)]; 102 - const results = await Promise.allSettled(unique.map(resolveIdentity)); 103 - const map: Record<string, MiniDoc> = {}; 104 - for (const result of results) { 105 - if (result.status === "fulfilled") map[result.value.did] = result.value; 106 - } 107 - return map; 108 62 } 109 63 110 64 export async function getRecord( ··· 112 66 collection: string, 113 67 rkey: string, 114 68 ): Promise<ATRecord> { 115 - return fetchJson<ATRecord>( 116 - `${SLINGSHOT}/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`, 117 - ); 69 + return queryClient.ensureQueryData({ 70 + queryKey: ["record", did, collection, rkey], 71 + queryFn: () => fetchRecord(did, collection, rkey), 72 + staleTime: STALE_SLOW, 73 + }); 118 74 } 119 75 120 76 export async function getRecordByUri(uri: string): Promise<ATRecord> { ··· 123 79 } 124 80 125 81 export async function getRecordsByUri(uris: string[]): Promise<ATRecord[]> { 126 - const results = await Promise.allSettled( 127 - uris.map((uri) => getRecordByUri(uri)), 128 - ); 82 + const results = await Promise.allSettled(uris.map(getRecordByUri)); 129 83 return results 130 84 .filter( 131 85 (result): result is PromiseFulfilledResult<ATRecord> => ··· 148 102 .map((result) => result.value); 149 103 } 150 104 105 + export async function listRecords( 106 + pdsUrl: string, 107 + did: string, 108 + collection: string, 109 + limit = 100, 110 + ): Promise<{ uri: string; cid: string; value: Record<string, unknown> }[]> { 111 + const all: ListRecordsResponse["records"] = []; 112 + let cursor: string | undefined; 113 + while (true) { 114 + let url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=${limit}`; 115 + if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; 116 + try { 117 + const data = await fetchJson<ListRecordsResponse>(url); 118 + all.push(...data.records); 119 + if (!data.cursor) break; 120 + cursor = data.cursor; 121 + } catch { 122 + break; 123 + } 124 + } 125 + return all; 126 + } 127 + 128 + // --- Identity (DID doc) --- 129 + 130 + export async function fetchIdentityDoc(identifier: string): Promise<MiniDoc> { 131 + return fetchJson<MiniDoc>( 132 + `${SLINGSHOT}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, 133 + ); 134 + } 135 + 136 + export async function resolveIdentity(identifier: string): Promise<MiniDoc> { 137 + const doc = await queryClient.ensureQueryData({ 138 + queryKey: ["identity", identifier], 139 + queryFn: () => fetchIdentityDoc(identifier), 140 + staleTime: STALE_SLOW, 141 + }); 142 + // Seed the DID-keyed entry too, so later DID lookups hit cache. 143 + if (doc.did !== identifier) { 144 + queryClient.setQueryData(["identity", doc.did], doc); 145 + } 146 + return doc; 147 + } 148 + 149 + export async function resolveIdentitiesBatch( 150 + ids: string[], 151 + ): Promise<Record<string, MiniDoc>> { 152 + const unique = [...new Set(ids)]; 153 + const results = await Promise.allSettled(unique.map(resolveIdentity)); 154 + const map: Record<string, MiniDoc> = {}; 155 + for (const result of results) { 156 + if (result.status === "fulfilled") map[result.value.did] = result.value; 157 + } 158 + return map; 159 + } 160 + 161 + // --- Avatar --- 162 + 163 + function extractAvatarCid(value: Record<string, unknown>): string | null { 164 + const avatar = value.avatar as { ref?: { $link?: string } } | undefined; 165 + return avatar?.ref?.$link ?? null; 166 + } 167 + 168 + export async function fetchAvatarUrl(did: string): Promise<string | null> { 169 + try { 170 + const record = await getRecord(did, BSKY_PROFILE, "self"); 171 + const cid = extractAvatarCid(record.value); 172 + return cid ? `${BSKY_CDN}/img/avatar/plain/${did}/${cid}` : null; 173 + } catch { 174 + return null; 175 + } 176 + } 177 + 178 + export async function getAvatar(did: string): Promise<string | undefined> { 179 + const url = await queryClient.ensureQueryData({ 180 + queryKey: ["avatar", did], 181 + queryFn: () => fetchAvatarUrl(did), 182 + staleTime: STALE_SLOW, 183 + }); 184 + return url ?? undefined; 185 + } 186 + 187 + export async function getAvatars( 188 + dids: string[], 189 + ): Promise<Record<string, string>> { 190 + const unique = [...new Set(dids)]; 191 + const urls = await Promise.all(unique.map(getAvatar)); 192 + const map: Record<string, string> = {}; 193 + unique.forEach((did, index) => { 194 + const url = urls[index]; 195 + if (url) map[did] = url; 196 + }); 197 + return map; 198 + } 199 + 200 + // --- Backlinks (Constellation) --- 201 + 151 202 export async function getBacklinks( 152 203 subject: string, 153 204 source: string, ··· 159 210 return fetchJson<BacklinksResponse>(url); 160 211 } 161 212 162 - export async function getBacklinkCount( 213 + export async function fetchBacklinkCount( 163 214 subject: string, 164 215 source: string, 165 216 ): Promise<number> { 166 - const key = `${source}\t${subject}`; 167 - const cached = backlinkCountCache.get(key); 168 - if (cached !== undefined) return cached; 169 217 try { 170 218 const { total } = await getBacklinks(subject, source, 1); 171 - backlinkCountCache.set(key, total); 172 219 return total; 173 220 } catch { 174 221 return 0; 175 222 } 176 223 } 177 224 225 + export async function getBacklinkCount( 226 + subject: string, 227 + source: string, 228 + ): Promise<number> { 229 + return queryClient.ensureQueryData({ 230 + queryKey: ["backlink-count", source, subject], 231 + queryFn: () => fetchBacklinkCount(subject, source), 232 + }); 233 + } 234 + 178 235 export async function getBacklinkCountsBatch( 179 236 subjects: string[], 180 237 source: string, ··· 189 246 }); 190 247 return map; 191 248 } 249 + 250 + // --- Fetch-and-hydrate (backlinks -> records -> identities) --- 192 251 193 252 interface HydratedRecord { 194 253 uri: string; ··· 248 307 249 308 return { records: hydrated, cursor: backlinks.cursor ?? null }; 250 309 } 251 - 252 - export async function listRecords( 253 - pdsUrl: string, 254 - did: string, 255 - collection: string, 256 - limit = 100, 257 - ): Promise<{ uri: string; cid: string; value: Record<string, unknown> }[]> { 258 - const all: ListRecordsResponse["records"] = []; 259 - let cursor: string | undefined; 260 - while (true) { 261 - let url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=${limit}`; 262 - if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; 263 - try { 264 - const data = await fetchJson<ListRecordsResponse>(url); 265 - all.push(...data.records); 266 - if (!data.cursor) break; 267 - cursor = data.cursor; 268 - } catch { 269 - break; 270 - } 271 - } 272 - return all; 273 - }
+15 -60
web/src/lib/bbs.ts
··· 1 1 /** Resolve a handle to a fully hydrated BBS via Slingshot/Constellation. */ 2 2 3 - import { TTLCache } from "./cache"; 4 3 import { 5 4 getRecord, 6 - getRecordsBatch, 7 - getBacklinks, 8 5 resolveIdentity, 9 6 type MiniDoc, 10 7 type ATRecord, 11 8 } from "./atproto"; 12 - import { SITE, BOARD, POST, BAN, HIDE } from "./lexicon"; 13 - import { makeAtUri, parseAtUri } from "./util"; 9 + import { queryClient } from "./queryClient"; 10 + import { SITE } from "./lexicon"; 11 + import { parseAtUri } from "./util"; 14 12 import { is } from "@atcute/lexicons/validations"; 15 13 import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 16 14 import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; 17 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 18 - import type { XyzAtbbsSite, XyzAtbbsBoard, XyzAtbbsPost } from "../lexicons"; 15 + import type { XyzAtbbsSite, XyzAtbbsBoard } from "../lexicons"; 19 16 20 17 export class BBSNotFoundError extends Error {} 21 18 export class NoBBSError extends Error {} 22 - export class NetworkError extends Error {} 23 19 24 20 export interface Board { 25 21 slug: string; ··· 55 51 export interface BBS { 56 52 identity: MiniDoc; 57 53 site: Site; 58 - news: NewsPost[]; 59 54 } 60 55 61 - const bbsCache = new TTLCache<string, BBS>(5 * 60 * 1000); 62 - 63 - export function invalidateBBSCache() { 64 - bbsCache.clear(); 56 + export function invalidateAllBBSCaches() { 57 + queryClient.invalidateQueries({ queryKey: ["bbs"] }); 58 + queryClient.invalidateQueries({ queryKey: ["bbs-moderation"] }); 59 + queryClient.invalidateQueries({ queryKey: ["sysop-moderation"] }); 65 60 } 66 61 67 62 export async function resolveBBS(handle: string): Promise<BBS> { 68 - const cached = bbsCache.get(handle); 69 - if (cached) return cached; 70 - const bbs = await _resolveBBS(handle); 71 - bbsCache.set(handle, bbs); 72 - return bbs; 73 - } 74 - 75 - async function _resolveBBS(handle: string): Promise<BBS> { 76 63 let identity: MiniDoc; 77 64 try { 78 65 identity = await resolveIdentity(handle); 79 - } catch (e) { 66 + } catch { 80 67 throw new BBSNotFoundError(`Could not resolve handle: ${handle}`); 81 68 } 82 69 if (!identity.pds) { ··· 94 81 throw new NoBBSError(`${handle} has an invalid site record.`); 95 82 } 96 83 const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; 97 - const siteUri = makeAtUri(identity.did, SITE, "self"); 98 84 const boardUris: string[] = siteValue.boards ?? []; 99 85 100 - const [boardResults, newsBacklinks] = await Promise.all([ 101 - Promise.allSettled( 102 - boardUris.map((uri) => { 103 - const parsed = parseAtUri(uri); 104 - return getRecord(parsed.did, parsed.collection, parsed.rkey); 105 - }), 106 - ), 107 - getBacklinks(siteUri, `${POST}:scope`, 50).catch(() => null), 108 - ]); 86 + const boardResults = await Promise.allSettled( 87 + boardUris.map((uri) => { 88 + const parsed = parseAtUri(uri); 89 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 90 + }), 91 + ); 109 92 110 93 const boards: Board[] = []; 111 94 boardResults.forEach((result, index) => { ··· 122 105 }); 123 106 }); 124 107 125 - // News - posts scoped to the site, only sysop's repo 126 - let news: NewsPost[] = []; 127 - if (newsBacklinks) { 128 - const sysopRefs = newsBacklinks.records.filter( 129 - (ref) => ref.did === identity.did, 130 - ); 131 - const newsRecords = await getRecordsBatch(sysopRefs); 132 - news = newsRecords 133 - .filter((record) => is(postSchema, record.value)) 134 - .filter((record) => { 135 - const value = record.value as unknown as XyzAtbbsPost.Main; 136 - return value.title && !value.root; // root posts with titles are news/threads 137 - }) 138 - .map((record) => { 139 - const value = record.value as unknown as XyzAtbbsPost.Main; 140 - return { 141 - uri: record.uri, 142 - rkey: parseAtUri(record.uri).rkey, 143 - title: value.title ?? "", 144 - body: value.body, 145 - createdAt: value.createdAt, 146 - attachments: value.attachments as PostAttachment[] | undefined, 147 - }; 148 - }); 149 - news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 150 - } 151 - 152 108 return { 153 109 identity, 154 110 site: { ··· 159 115 createdAt: siteValue.createdAt ?? "", 160 116 updatedAt: siteValue.updatedAt, 161 117 }, 162 - news, 163 118 }; 164 119 }
+49
web/src/lib/bbsModeration.ts
··· 1 + /** Lookup tables for a BBS's moderation state: who is banned, which posts 2 + * are hidden, and the rkeys of those records (so the sysop can undo). */ 3 + 4 + import { listRecords } from "./atproto"; 5 + import { BAN, HIDE } from "./lexicon"; 6 + import { parseAtUri } from "./util"; 7 + import { is } from "@atcute/lexicons/validations"; 8 + import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; 9 + import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; 10 + import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons"; 11 + 12 + export interface BBSModeration { 13 + bannedDids: Set<string>; 14 + hiddenUris: Set<string>; 15 + /** DID → rkey of that user's ban record on the sysop's PDS. */ 16 + banRkeys: Record<string, string>; 17 + /** Post URI → rkey of its hide record on the sysop's PDS. */ 18 + hideRkeys: Record<string, string>; 19 + } 20 + 21 + export async function fetchBBSModeration( 22 + pdsUrl: string, 23 + did: string, 24 + ): Promise<BBSModeration> { 25 + const [banRecs, hideRecs] = await Promise.all([ 26 + listRecords(pdsUrl, did, BAN).catch(() => []), 27 + listRecords(pdsUrl, did, HIDE).catch(() => []), 28 + ]); 29 + 30 + const bannedDids = new Set<string>(); 31 + const banRkeys: Record<string, string> = {}; 32 + for (const record of banRecs) { 33 + if (!is(banSchema, record.value)) continue; 34 + const value = record.value as unknown as XyzAtbbsBan.Main; 35 + bannedDids.add(value.did); 36 + banRkeys[value.did] = parseAtUri(record.uri).rkey; 37 + } 38 + 39 + const hiddenUris = new Set<string>(); 40 + const hideRkeys: Record<string, string> = {}; 41 + for (const record of hideRecs) { 42 + if (!is(hideSchema, record.value)) continue; 43 + const value = record.value as unknown as XyzAtbbsHide.Main; 44 + hiddenUris.add(value.uri); 45 + hideRkeys[value.uri] = parseAtUri(record.uri).rkey; 46 + } 47 + 48 + return { bannedDids, hiddenUris, banRkeys, hideRkeys }; 49 + }
-25
web/src/lib/cache.ts
··· 1 - /** Simple in-memory cache with TTL. */ 2 - 3 - export class TTLCache<K, V> { 4 - private entries = new Map<K, { value: V; expires: number }>(); 5 - 6 - constructor(private ttl: number) {} 7 - 8 - get(key: K): V | undefined { 9 - const entry = this.entries.get(key); 10 - if (!entry) return undefined; 11 - if (entry.expires < Date.now()) { 12 - this.entries.delete(key); 13 - return undefined; 14 - } 15 - return entry.value; 16 - } 17 - 18 - set(key: K, value: V): void { 19 - this.entries.set(key, { value, expires: Date.now() + this.ttl }); 20 - } 21 - 22 - clear(): void { 23 - this.entries.clear(); 24 - } 25 - }
+64
web/src/lib/discovery.ts
··· 1 + /** Fetch a random list of BBSes from the Lightrail API, with avatars. */ 2 + 3 + import { getAvatars, getRecord, resolveIdentitiesBatch } from "./atproto"; 4 + import { SITE } from "./lexicon"; 5 + import { SERVICES } from "./shared"; 6 + import { is } from "@atcute/lexicons/validations"; 7 + import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 8 + import type { XyzAtbbsSite } from "../lexicons"; 9 + 10 + export interface DiscoveredBBS { 11 + did: string; 12 + handle: string; 13 + name: string; 14 + description: string; 15 + avatar?: string; 16 + } 17 + 18 + interface LightrailRepo { 19 + did: string; 20 + } 21 + 22 + export async function fetchDiscovery(): Promise<DiscoveredBBS[]> { 23 + let repos: LightrailRepo[] = []; 24 + try { 25 + const response = await fetch( 26 + `${SERVICES.lightrail}/com.atproto.sync.listReposByCollection?collection=${SITE}&limit=50`, 27 + ); 28 + const data = (await response.json()) as { repos: LightrailRepo[] }; 29 + repos = data.repos; 30 + } catch { 31 + return []; 32 + } 33 + if (!repos.length) return []; 34 + 35 + const shuffled = repos.sort(() => Math.random() - 0.5); 36 + const identities = await resolveIdentitiesBatch( 37 + shuffled.map((repo) => repo.did), 38 + ); 39 + 40 + const items: DiscoveredBBS[] = []; 41 + for (const repo of shuffled) { 42 + if (!(repo.did in identities)) continue; 43 + try { 44 + const siteRecord = await getRecord(repo.did, SITE, "self"); 45 + if (!is(siteSchema, siteRecord.value)) continue; 46 + const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; 47 + items.push({ 48 + did: repo.did, 49 + handle: identities[repo.did].handle, 50 + name: siteValue.name || identities[repo.did].handle, 51 + description: siteValue.description || "", 52 + }); 53 + } catch { 54 + continue; 55 + } 56 + } 57 + 58 + const avatars = await getAvatars(items.map((item) => item.did)); 59 + for (const item of items) { 60 + item.avatar = avatars[item.did]; 61 + } 62 + 63 + return items; 64 + }
+20
web/src/lib/home.ts
··· 1 + /** Minimal check for the dashboard: does this user run a BBS, and if so 2 + * what's it called? A full BBS fetch only happens on the BBS page itself. */ 3 + 4 + import { getRecord } from "./atproto"; 5 + import { SITE } from "./lexicon"; 6 + 7 + export interface HomeSysopInfo { 8 + hasBBS: boolean; 9 + bbsName: string | null; 10 + } 11 + 12 + export async function fetchHomeSysopInfo(did: string): Promise<HomeSysopInfo> { 13 + try { 14 + const record = await getRecord(did, SITE, "self"); 15 + const value = record.value as { name?: string }; 16 + return { hasBBS: true, bbsName: value.name ?? null }; 17 + } catch { 18 + return { hasBBS: false, bbsName: null }; 19 + } 20 + }
+41
web/src/lib/news.ts
··· 1 + /** Fetch the list of news posts a sysop has published to their site. */ 2 + 3 + import { getBacklinks, getRecordsBatch } from "./atproto"; 4 + import { POST, SITE } from "./lexicon"; 5 + import { makeAtUri, parseAtUri } from "./util"; 6 + import { is } from "@atcute/lexicons/validations"; 7 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 8 + import type { XyzAtbbsPost } from "../lexicons"; 9 + import type { NewsPost } from "./bbs"; 10 + 11 + export async function fetchNews(bbsDid: string): Promise<NewsPost[]> { 12 + const siteUri = makeAtUri(bbsDid, SITE, "self"); 13 + const backlinks = await getBacklinks(siteUri, `${POST}:scope`, 50).catch( 14 + () => null, 15 + ); 16 + if (!backlinks) return []; 17 + 18 + const sysopRefs = backlinks.records.filter((ref) => ref.did === bbsDid); 19 + const records = await getRecordsBatch(sysopRefs); 20 + 21 + const news: NewsPost[] = records 22 + .filter((record) => is(postSchema, record.value)) 23 + .filter((record) => { 24 + const value = record.value as unknown as XyzAtbbsPost.Main; 25 + return value.title && !value.root; 26 + }) 27 + .map((record) => { 28 + const value = record.value as unknown as XyzAtbbsPost.Main; 29 + return { 30 + uri: record.uri, 31 + rkey: parseAtUri(record.uri).rkey, 32 + title: value.title ?? "", 33 + body: value.body, 34 + createdAt: value.createdAt, 35 + attachments: value.attachments as NewsPost["attachments"], 36 + }; 37 + }); 38 + 39 + news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 40 + return news; 41 + }
+149
web/src/lib/queries.ts
··· 1 + /** Query-key factories. Every useQuery/useMutation in the app goes through 2 + * one of these so query keys live in one place. */ 3 + 4 + import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; 5 + import { 6 + fetchIdentityDoc, 7 + fetchAvatarUrl, 8 + fetchBacklinkCount, 9 + } from "./atproto"; 10 + import { STALE_SLOW } from "./queryClient"; 11 + import { resolveBBS } from "./bbs"; 12 + import { fetchNews } from "./news"; 13 + import { fetchProfile } from "./profile"; 14 + import { fetchMyThreads } from "./mythreads"; 15 + import { fetchActivity } from "./activity"; 16 + import { fetchPins } from "./pins"; 17 + import { fetchDiscovery } from "./discovery"; 18 + import { fetchHomeSysopInfo } from "./home"; 19 + import { fetchSysopModeration } from "./sysopModeration"; 20 + import { fetchBBSModeration } from "./bbsModeration"; 21 + import { hydrateThreadPage } from "./boardThreads"; 22 + import { fetchThreadRefs, fetchThreadRoot, hydrateReplyPage } from "./thread"; 23 + import type { BacklinkRef } from "./atproto"; 24 + 25 + // Shared by slow-changing queries: 5-minute staleTime, and skip the 26 + // "refetch on every mount" default that live queries use. 27 + const slowQueryOpts = { staleTime: STALE_SLOW, refetchOnMount: true } as const; 28 + 29 + export const bbsQuery = (handle: string) => 30 + queryOptions({ 31 + ...slowQueryOpts, 32 + queryKey: ["bbs", handle] as const, 33 + queryFn: () => resolveBBS(handle), 34 + }); 35 + 36 + export const newsQuery = (bbsDid: string) => 37 + queryOptions({ 38 + queryKey: ["news", bbsDid] as const, 39 + queryFn: () => fetchNews(bbsDid), 40 + }); 41 + 42 + export const identityQuery = (identifier: string) => 43 + queryOptions({ 44 + ...slowQueryOpts, 45 + queryKey: ["identity", identifier] as const, 46 + queryFn: () => fetchIdentityDoc(identifier), 47 + }); 48 + 49 + export const avatarQuery = (did: string) => 50 + queryOptions({ 51 + ...slowQueryOpts, 52 + queryKey: ["avatar", did] as const, 53 + queryFn: () => fetchAvatarUrl(did), 54 + }); 55 + 56 + export const backlinkCountQuery = (subject: string, source: string) => 57 + queryOptions({ 58 + queryKey: ["backlink-count", source, subject] as const, 59 + queryFn: () => fetchBacklinkCount(subject, source), 60 + }); 61 + 62 + export const profileQuery = (handle: string) => 63 + queryOptions({ 64 + ...slowQueryOpts, 65 + queryKey: ["profile", handle] as const, 66 + queryFn: () => fetchProfile(handle), 67 + }); 68 + 69 + export const myThreadsQuery = (pdsUrl: string, did: string) => 70 + queryOptions({ 71 + queryKey: ["my-threads", did] as const, 72 + queryFn: () => fetchMyThreads(pdsUrl, did), 73 + }); 74 + 75 + export const activityQuery = (pdsUrl: string, did: string) => 76 + queryOptions({ 77 + queryKey: ["activity", did] as const, 78 + queryFn: () => fetchActivity(did, pdsUrl), 79 + }); 80 + 81 + export const pinsQuery = (pdsUrl: string, did: string) => 82 + queryOptions({ 83 + queryKey: ["pins", did] as const, 84 + queryFn: () => fetchPins(pdsUrl, did), 85 + }); 86 + 87 + export const discoveryQuery = () => 88 + queryOptions({ 89 + queryKey: ["discovery"] as const, 90 + queryFn: fetchDiscovery, 91 + }); 92 + 93 + export const homeSysopQuery = (did: string) => 94 + queryOptions({ 95 + queryKey: ["home-sysop", did] as const, 96 + queryFn: () => fetchHomeSysopInfo(did), 97 + }); 98 + 99 + export const sysopModerationQuery = (pdsUrl: string, did: string) => 100 + queryOptions({ 101 + queryKey: ["sysop-moderation", did] as const, 102 + queryFn: () => fetchSysopModeration(pdsUrl, did), 103 + }); 104 + 105 + export const bbsModerationQuery = (pdsUrl: string, did: string) => 106 + queryOptions({ 107 + queryKey: ["bbs-moderation", did] as const, 108 + queryFn: () => fetchBBSModeration(pdsUrl, did), 109 + }); 110 + 111 + export const boardThreadsInfiniteQuery = (bbsDid: string, slug: string) => 112 + infiniteQueryOptions({ 113 + queryKey: ["board-threads", bbsDid, slug] as const, 114 + queryFn: ({ pageParam }: { pageParam: string | undefined }) => 115 + hydrateThreadPage(bbsDid, slug, pageParam), 116 + initialPageParam: undefined as string | undefined, 117 + getNextPageParam: (last) => last.cursor ?? undefined, 118 + refetchOnMount: "always", 119 + }); 120 + 121 + export const threadRefsQuery = (threadUri: string) => 122 + queryOptions({ 123 + queryKey: ["thread-refs", threadUri] as const, 124 + queryFn: () => fetchThreadRefs(threadUri), 125 + }); 126 + 127 + export const threadRootQuery = (did: string, tid: string) => 128 + queryOptions({ 129 + queryKey: ["thread-root", did, tid] as const, 130 + queryFn: () => fetchThreadRoot(did, tid), 131 + }); 132 + 133 + export const threadPageQuery = ( 134 + threadUri: string, 135 + page: number, 136 + pageRefs: BacklinkRef[], 137 + ) => 138 + queryOptions({ 139 + // Fingerprint is part of the key so that when the thread-refs cache 140 + // gets updated (new replies, deletes), this page's cache entry gets 141 + // a new key and refetches — rather than serving a stale hydration. 142 + queryKey: [ 143 + "thread-page", 144 + threadUri, 145 + page, 146 + pageRefs.map((ref) => ref.rkey).join("/"), 147 + ] as const, 148 + queryFn: () => hydrateReplyPage(pageRefs), 149 + });
+20
web/src/lib/queryClient.ts
··· 1 + import { QueryClient } from "@tanstack/react-query"; 2 + 3 + /** Stays fresh for 30s: posts, replies, activity, counts. */ 4 + export const STALE_LIVE = 30 * 1000; 5 + 6 + /** Stays fresh for 5 min: identities, avatars, site records, profiles. */ 7 + export const STALE_SLOW = 5 * 60 * 1000; 8 + 9 + export const queryClient = new QueryClient({ 10 + defaultOptions: { 11 + queries: { 12 + staleTime: STALE_LIVE, 13 + // Every page navigation refetches live data. Slow queries opt out 14 + // via `refetchOnMount: true` in queries.ts. 15 + refetchOnMount: "always", 16 + refetchOnWindowFocus: true, 17 + refetchOnReconnect: true, 18 + }, 19 + }, 20 + });
+129
web/src/lib/thread.ts
··· 1 + /** Thread detail fetchers: root post, reply refs, and the hydrated 2 + * reply records for one page of the thread. */ 3 + 4 + import { 5 + getBacklinks, 6 + getRecord, 7 + getRecordsBatch, 8 + resolveIdentitiesBatch, 9 + resolveIdentity, 10 + type BacklinkRef, 11 + } from "./atproto"; 12 + import { POST } from "./lexicon"; 13 + import { makeAtUri, parseAtUri } from "./util"; 14 + import { recordToReply } from "./replies"; 15 + import type { Reply } from "../components/post/ReplyCard"; 16 + import { is } from "@atcute/lexicons/validations"; 17 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 18 + import type { XyzAtbbsPost } from "../lexicons"; 19 + 20 + export interface ThreadRoot { 21 + uri: string; 22 + did: string; 23 + rkey: string; 24 + authorHandle: string; 25 + authorPds: string; 26 + title: string; 27 + body: string; 28 + createdAt: string; 29 + boardSlug: string; 30 + attachments?: { file: { ref: { $link: string } }; name: string }[]; 31 + } 32 + 33 + const MAX_REF_PAGES = 20; 34 + const REF_PAGE_SIZE = 100; 35 + 36 + /** Every reply ref for the thread, oldest-first. */ 37 + export async function fetchThreadRefs( 38 + threadUri: string, 39 + ): Promise<BacklinkRef[]> { 40 + const collected: BacklinkRef[] = []; 41 + let cursor: string | undefined; 42 + for (let i = 0; i < MAX_REF_PAGES; i++) { 43 + const page = await getBacklinks( 44 + threadUri, 45 + `${POST}:root`, 46 + REF_PAGE_SIZE, 47 + cursor, 48 + ); 49 + collected.push(...page.records); 50 + if (!page.cursor) break; 51 + cursor = page.cursor; 52 + } 53 + return collected.reverse(); 54 + } 55 + 56 + export async function fetchThreadRoot( 57 + did: string, 58 + tid: string, 59 + ): Promise<ThreadRoot> { 60 + const threadRecord = await getRecord(did, POST, tid); 61 + if (!is(postSchema, threadRecord.value)) { 62 + throw new Error("Invalid post record"); 63 + } 64 + const author = await resolveIdentity(did); 65 + const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; 66 + const boardSlug = parseAtUri(postValue.scope).rkey; 67 + return { 68 + uri: threadRecord.uri, 69 + did, 70 + rkey: tid, 71 + authorHandle: author.handle, 72 + authorPds: author.pds ?? "", 73 + title: postValue.title ?? "", 74 + body: postValue.body, 75 + createdAt: postValue.createdAt, 76 + boardSlug, 77 + attachments: postValue.attachments as ThreadRoot["attachments"], 78 + }; 79 + } 80 + 81 + export function threadUriFor(did: string, tid: string): string { 82 + return makeAtUri(did, POST, tid); 83 + } 84 + 85 + export interface ReplyPage { 86 + replies: Reply[]; 87 + /** Lookup by URI for any reply referenced as a parent — includes both 88 + * on-page replies and off-page parents fetched separately. */ 89 + parentReplies: Record<string, Reply>; 90 + } 91 + 92 + export async function hydrateReplyPage( 93 + pageRefs: BacklinkRef[], 94 + ): Promise<ReplyPage> { 95 + if (!pageRefs.length) return { replies: [], parentReplies: {} }; 96 + 97 + const records = await getRecordsBatch(pageRefs); 98 + const authors = await resolveIdentitiesBatch( 99 + records.map((r) => parseAtUri(r.uri).did), 100 + ); 101 + const replies: Reply[] = records 102 + .map((record) => recordToReply(record, authors)) 103 + .filter((reply): reply is Reply => reply !== null) 104 + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); 105 + 106 + const parentReplies: Record<string, Reply> = {}; 107 + for (const reply of replies) parentReplies[reply.uri] = reply; 108 + 109 + const offPageParentUris = [ 110 + ...new Set( 111 + replies 112 + .map((r) => r.parent) 113 + .filter((uri): uri is string => !!uri && !parentReplies[uri]), 114 + ), 115 + ]; 116 + if (offPageParentUris.length) { 117 + const parentRefs = offPageParentUris.map((uri) => parseAtUri(uri)); 118 + const parentRecords = await getRecordsBatch(parentRefs); 119 + const parentAuthors = await resolveIdentitiesBatch( 120 + parentRecords.map((r) => parseAtUri(r.uri).did), 121 + ); 122 + for (const record of parentRecords) { 123 + const reply = recordToReply(record, parentAuthors); 124 + if (reply) parentReplies[reply.uri] = reply; 125 + } 126 + } 127 + 128 + return { replies, parentReplies }; 129 + }
+5
web/src/lib/util.ts
··· 29 29 return { did: parts[2], collection: parts[3], rkey: parts[4] }; 30 30 } 31 31 32 + export function truncate(text: string, maxLength: number): string { 33 + if (text.length <= maxLength) return text; 34 + return text.substring(0, maxLength) + "..."; 35 + } 36 + 32 37 import type { Did } from "@atcute/lexicons/syntax"; 33 38 34 39 export function makeAtUri(
+5 -5
web/src/lib/writes.ts
··· 2 2 3 3 import type { Client } from "@atcute/client"; 4 4 import { SITE, BOARD, POST, BAN, HIDE, PIN, PROFILE } from "./lexicon"; 5 - import { invalidateBBSCache } from "./bbs"; 5 + import { invalidateAllBBSCaches } from "./bbs"; 6 6 import { nowIso } from "./util"; 7 7 import { getCurrentUser } from "./auth"; 8 8 import type { ··· 188 188 189 189 export async function putSite(rpc: Client, site: SiteValue) { 190 190 const resp = await putRecord(rpc, SITE, "self", site); 191 - invalidateBBSCache(); 191 + invalidateAllBBSCaches(); 192 192 return resp; 193 193 } 194 194 ··· 205 205 createdAt: createdAt as BoardValue["createdAt"], 206 206 }; 207 207 const resp = await putRecord(rpc, BOARD, slug, value); 208 - invalidateBBSCache(); 208 + invalidateAllBBSCaches(); 209 209 return resp; 210 210 } 211 211 ··· 217 217 createdAt: nowIso(), 218 218 }; 219 219 const resp = await createRecord(rpc, BAN, value); 220 - invalidateBBSCache(); 220 + invalidateAllBBSCaches(); 221 221 return resp; 222 222 } 223 223 ··· 227 227 createdAt: nowIso(), 228 228 }; 229 229 const resp = await createRecord(rpc, HIDE, value); 230 - invalidateBBSCache(); 230 + invalidateAllBBSCaches(); 231 231 return resp; 232 232 } 233 233
+11 -3
web/src/main.tsx
··· 1 1 import { StrictMode } from "react"; 2 2 import { createRoot } from "react-dom/client"; 3 3 import { RouterProvider } from "react-router-dom"; 4 + import { QueryClientProvider } from "@tanstack/react-query"; 5 + import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 6 + import { queryClient } from "./lib/queryClient"; 4 7 import { router } from "./router/routes"; 5 8 import { BreadcrumbProvider } from "./hooks/useBreadcrumb"; 6 9 import "./index.css"; ··· 11 14 12 15 createRoot(document.getElementById("root")!).render( 13 16 <StrictMode> 14 - <BreadcrumbProvider> 15 - <RouterProvider router={router} /> 16 - </BreadcrumbProvider> 17 + <QueryClientProvider client={queryClient}> 18 + <BreadcrumbProvider> 19 + <RouterProvider router={router} /> 20 + </BreadcrumbProvider> 21 + {import.meta.env.DEV && ( 22 + <ReactQueryDevtools buttonPosition="bottom-left" /> 23 + )} 24 + </QueryClientProvider> 17 25 </StrictMode>, 18 26 );
+84 -58
web/src/pages/BBS.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 - import { Link, useLocation, useRouteLoaderData } from "react-router-dom"; 3 - import { useAuth } from "../lib/auth"; 4 - import { useBreadcrumb } from "../hooks/useBreadcrumb"; 5 - import { createPost, uploadAttachments } from "../lib/writes"; 6 - import ComposeForm from "../components/form/ComposeForm"; 7 - import { SITE } from "../lib/lexicon"; 8 - import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; 9 - import * as limits from "../lib/limits"; 10 - import { usePageTitle } from "../hooks/usePageTitle"; 11 - import Localtime from "../components/Localtime"; 12 - import ListLink from "../components/nav/ListLink"; 13 - import ActionBar from "../components/nav/ActionBar"; 14 - import { ActionLink } from "../components/nav/ActionButton"; 15 - import PinButton from "../components/PinButton"; 2 + import { Link, useParams } from "react-router-dom"; 3 + import { useSuspenseQuery, useMutation, useQuery } from "@tanstack/react-query"; 16 4 import { 17 5 User, 18 6 Pencil, ··· 22 10 Megaphone, 23 11 ChevronDown, 24 12 } from "lucide-react"; 13 + import { useAuth } from "../lib/auth"; 14 + import { useBreadcrumb } from "../hooks/useBreadcrumb"; 15 + import { usePageTitle } from "../hooks/usePageTitle"; 16 + import { createPost, uploadAttachments } from "../lib/writes"; 17 + import { findPinRkey } from "../lib/pins"; 18 + import { SITE } from "../lib/lexicon"; 19 + import { makeAtUri, nowIso, parseAtUri, truncate } from "../lib/util"; 20 + import * as limits from "../lib/limits"; 21 + import { bbsQuery, newsQuery, pinsQuery } from "../lib/queries"; 22 + import { queryClient } from "../lib/queryClient"; 25 23 import type { NewsPost } from "../lib/bbs"; 26 - import type { BBSLoaderData } from "../router/loaders"; 27 - import PostBody from "../components/post/PostBody"; 24 + import ComposeForm from "../components/form/ComposeForm"; 25 + import Localtime from "../components/Localtime"; 26 + import ListLink from "../components/nav/ListLink"; 27 + import ActionBar from "../components/nav/ActionBar"; 28 + import { ActionLink } from "../components/nav/ActionButton"; 29 + import PinButton from "../components/PinButton"; 30 + 31 + const INITIAL_NEWS_COUNT = 3; 28 32 29 33 export default function BBSPage() { 30 - const { handle, bbs, pinRkey } = useRouteLoaderData("bbs") as BBSLoaderData; 34 + const { handle } = useParams(); 31 35 const { user, agent } = useAuth(); 32 - // Set when arriving here via a news delete; Constellation/Slingshot may 33 - // still be returning the record, so filter it out for this one render. 34 - const justDeletedRkey = (useLocation().state as { deletedNewsRkey?: string }) 35 - ?.deletedNewsRkey; 36 36 const [newsTitle, setNewsTitle] = useState(""); 37 37 const [newsBody, setNewsBody] = useState(""); 38 38 const [newsFiles, setNewsFiles] = useState<File[]>([]); 39 - const [pendingNews, setPendingNews] = useState<NewsPost[]>([]); 40 39 const [showAllNews, setShowAllNews] = useState(false); 41 - const [postingNews, setPostingNews] = useState(false); 40 + 41 + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); 42 + const { data: news } = useSuspenseQuery(newsQuery(bbs.identity.did)); 43 + const { data: pins } = useQuery({ 44 + ...pinsQuery(user?.pdsUrl ?? "", user?.did ?? ""), 45 + enabled: !!user, 46 + }); 47 + const pinRkey = user && pins ? findPinRkey(pins, bbs.identity.did) : null; 42 48 43 49 useBreadcrumb( 44 50 [{ label: bbs.site.name, to: `/bbs/${handle}` }], ··· 48 54 49 55 const isSysop = user && user.did === bbs.identity.did; 50 56 51 - async function postNews(e: SyntheticEvent) { 52 - e.preventDefault(); 53 - if (!agent || postingNews) return; 54 - setPostingNews(true); 55 - try { 56 - const title = newsTitle.trim(); 57 - const body = newsBody.trim(); 57 + const postNewsMutation = useMutation({ 58 + mutationFn: async (input: { 59 + title: string; 60 + body: string; 61 + files: File[]; 62 + }) => { 63 + if (!agent) throw new Error("Not signed in"); 58 64 const siteUri = makeAtUri(bbs.identity.did, SITE, "self"); 59 - const attachments = await uploadAttachments(agent, newsFiles); 60 - const resp = await createPost(agent, siteUri, body, { 61 - title, 65 + const attachments = await uploadAttachments(agent, input.files); 66 + const resp = await createPost(agent, siteUri, input.body, { 67 + title: input.title, 62 68 attachments, 63 69 }); 70 + return { resp, attachments }; 71 + }, 72 + onSuccess: ({ resp, attachments }, input) => { 64 73 const rkey = parseAtUri(resp.data.uri).rkey; 65 - setPendingNews((prev) => [ 66 - { uri: resp.data.uri, rkey, title, body, createdAt: nowIso() }, 67 - ...prev, 68 - ]); 74 + const newItem: NewsPost = { 75 + uri: resp.data.uri, 76 + rkey, 77 + title: input.title, 78 + body: input.body, 79 + createdAt: nowIso(), 80 + attachments: attachments.length 81 + ? (attachments as NewsPost["attachments"]) 82 + : undefined, 83 + }; 84 + queryClient.setQueryData<NewsPost[]>( 85 + newsQuery(bbs.identity.did).queryKey, 86 + (prev) => [newItem, ...(prev ?? [])], 87 + ); 69 88 setNewsTitle(""); 70 89 setNewsBody(""); 71 90 setNewsFiles([]); 72 - } catch (error: unknown) { 91 + }, 92 + onError: (error) => { 73 93 alert( 74 94 `Could not post: ${error instanceof Error ? error.message : error}`, 75 95 ); 76 - } finally { 77 - setPostingNews(false); 78 - } 96 + }, 97 + }); 98 + 99 + function onPostNews(event: SyntheticEvent) { 100 + event.preventDefault(); 101 + if (postNewsMutation.isPending) return; 102 + postNewsMutation.mutate({ 103 + title: newsTitle.trim(), 104 + body: newsBody.trim(), 105 + files: newsFiles, 106 + }); 79 107 } 80 108 81 - // Merge pending news with loader data, deduplicating by rkey and dropping 82 - // anything just deleted from the News page. 83 - const loaderTids = new Set(bbs.news.map((n) => n.rkey)); 84 - const allNews = [ 85 - ...pendingNews.filter((n) => !loaderTids.has(n.rkey)), 86 - ...bbs.news, 87 - ].filter((n) => n.rkey !== justDeletedRkey); 88 - const visibleNews = showAllNews ? allNews : allNews.slice(0, 3); 109 + const visibleNews = showAllNews ? news : news.slice(0, INITIAL_NEWS_COUNT); 89 110 90 111 return ( 91 112 <> ··· 93 114 <h1 className="text-lg text-neutral-200 mb-1">{bbs.site.name}</h1> 94 115 <p className="text-neutral-400 mb-3">{bbs.site.description}</p> 95 116 <ActionBar> 96 - <PinButton bbsDid={bbs.identity.did} initialRkey={pinRkey} /> 97 - <ActionLink to={`/profile/${encodeURIComponent(handle)}`} icon={User}> 117 + <PinButton 118 + key={bbs.identity.did} 119 + bbsDid={bbs.identity.did} 120 + initialRkey={pinRkey} 121 + /> 122 + <ActionLink 123 + to={`/profile/${encodeURIComponent(handle!)}`} 124 + icon={User} 125 + > 98 126 owner 99 127 </ActionLink> 100 128 {isSysop && ( ··· 144 172 </summary> 145 173 <ComposeForm 146 174 className="mt-4" 147 - onSubmit={postNews} 175 + onSubmit={onPostNews} 148 176 title={newsTitle} 149 177 onTitleChange={setNewsTitle} 150 178 titlePlaceholder="Headline" ··· 157 185 files={newsFiles} 158 186 onFilesChange={setNewsFiles} 159 187 submitLabel="post" 160 - posting={postingNews} 188 + posting={postNewsMutation.isPending} 161 189 /> 162 190 </details> 163 191 )} 164 192 165 - {allNews.length ? ( 193 + {news.length ? ( 166 194 <> 167 195 {visibleNews.map((item, i) => ( 168 196 <Link 169 197 key={item.rkey} 170 198 to={`/bbs/${handle}/news/${item.rkey}`} 171 - state={{ pendingNewsItem: item }} 172 199 className={`reply-card block bg-neutral-900 border border-neutral-800 rounded p-4 hover:border-neutral-700 ${i < visibleNews.length - 1 ? "mb-2" : ""}`} 173 200 > 174 201 <div className="flex items-baseline gap-2 mb-2"> ··· 177 204 <Localtime iso={item.createdAt} /> 178 205 </div> 179 206 <div className="line-clamp-3 text-neutral-400"> 180 - {item.body.substring(0, 200) + 181 - (item.body.length > 200 ? "..." : "")} 207 + {truncate(item.body, 200)} 182 208 </div> 183 209 </Link> 184 210 ))} 185 - {!showAllNews && allNews.length > 3 && ( 211 + {!showAllNews && news.length > INITIAL_NEWS_COUNT && ( 186 212 <button 187 213 onClick={() => setShowAllNews(true)} 188 214 className="text-neutral-400 hover:text-neutral-300 text-xs mt-2 inline-flex items-center gap-1"
+77 -65
web/src/pages/Board.tsx
··· 1 - import { useEffect, useState, type SyntheticEvent } from "react"; 1 + import { useState, type SyntheticEvent } from "react"; 2 2 import { PenLine } from "lucide-react"; 3 + import { useNavigate, useParams } from "react-router-dom"; 3 4 import { 4 - useLoaderData, 5 - useNavigate, 6 - useRevalidator, 7 - useRouteLoaderData, 8 - } from "react-router-dom"; 5 + useMutation, 6 + useSuspenseInfiniteQuery, 7 + useSuspenseQuery, 8 + } from "@tanstack/react-query"; 9 9 import { useAuth } from "../lib/auth"; 10 10 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 11 11 import { usePageTitle } from "../hooks/usePageTitle"; ··· 13 13 import { BOARD } from "../lib/lexicon"; 14 14 import { createPost, uploadAttachments } from "../lib/writes"; 15 15 import * as limits from "../lib/limits"; 16 + import { 17 + bbsModerationQuery, 18 + bbsQuery, 19 + boardThreadsInfiniteQuery, 20 + } from "../lib/queries"; 21 + import { queryClient } from "../lib/queryClient"; 16 22 import ThreadLink, { ThreadListHeader } from "../components/nav/ThreadLink"; 17 23 import ComposeForm from "../components/form/ComposeForm"; 18 - import { 19 - hydrateThreadPage, 20 - type BBSLoaderData, 21 - type ThreadItem, 22 - } from "../router/loaders"; 23 - import type { Board as BoardType } from "../lib/bbs"; 24 - 25 - interface LoaderData { 26 - handle: string; 27 - board: BoardType; 28 - threads: ThreadItem[]; 29 - cursor: string | null; 30 - } 31 24 32 25 export default function BoardPage() { 33 - const { bbs } = useRouteLoaderData("bbs") as BBSLoaderData; 34 - const loaded = useLoaderData() as LoaderData; 35 - const { handle, board } = loaded; 26 + const { handle, slug } = useParams(); 36 27 const { user, agent } = useAuth(); 37 - const revalidator = useRevalidator(); 38 28 const navigate = useNavigate(); 39 29 40 - const [extraThreads, setExtraThreads] = useState<ThreadItem[]>([]); 41 - const [cursor, setCursor] = useState<string | null>(loaded.cursor); 42 - const [loadingMore, setLoadingMore] = useState(false); 30 + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); 31 + const board = bbs.site.boards.find((b) => b.slug === slug); 32 + if (!board) throw new Response("Board not found", { status: 404 }); 43 33 44 - useEffect(() => { 45 - setExtraThreads([]); 46 - setCursor(loaded.cursor); 47 - }, [loaded.threads, loaded.cursor]); 48 - 49 - const threads = [...loaded.threads, ...extraThreads]; 34 + const { 35 + data: threadPages, 36 + fetchNextPage, 37 + hasNextPage, 38 + isFetchingNextPage, 39 + } = useSuspenseInfiniteQuery( 40 + boardThreadsInfiniteQuery(bbs.identity.did, slug!), 41 + ); 42 + const { data: moderation } = useSuspenseQuery( 43 + bbsModerationQuery(bbs.identity.pds ?? "", bbs.identity.did), 44 + ); 45 + const isSysop = !!(user && user.did === bbs.identity.did); 46 + const allThreads = threadPages.pages.flatMap((page) => page.threads); 47 + const threads = isSysop 48 + ? allThreads 49 + : allThreads.filter( 50 + (t) => 51 + !moderation.bannedDids.has(t.did) && 52 + !moderation.hiddenUris.has(t.uri), 53 + ); 50 54 51 55 const [title, setTitle] = useState(""); 52 56 const [body, setBody] = useState(""); 53 57 const [files, setFiles] = useState<File[]>([]); 54 - const [posting, setPosting] = useState(false); 55 58 56 59 usePageTitle(`${board.name} — ${bbs.site.name}`); 57 60 useBreadcrumb( ··· 62 65 [bbs, board, handle], 63 66 ); 64 67 65 - async function loadMore() { 66 - if (!cursor) return; 67 - setLoadingMore(true); 68 - try { 69 - const page = await hydrateThreadPage(bbs, board.slug, cursor); 70 - setExtraThreads((prev) => [...prev, ...page.threads]); 71 - setCursor(page.cursor); 72 - } finally { 73 - setLoadingMore(false); 74 - } 75 - } 76 - 77 - async function onCreate(e: SyntheticEvent) { 78 - e.preventDefault(); 79 - if (!agent || !user || posting) return; 80 - setPosting(true); 81 - try { 68 + const createThreadMutation = useMutation({ 69 + mutationFn: async (input: { 70 + title: string; 71 + body: string; 72 + files: File[]; 73 + }) => { 74 + if (!agent) throw new Error("Not signed in"); 82 75 const boardUri = makeAtUri(bbs.identity.did, BOARD, board.slug); 83 - const attachments = await uploadAttachments(agent, files); 84 - const resp = await createPost(agent, boardUri, body.trim(), { 85 - title: title.trim(), 76 + const attachments = await uploadAttachments(agent, input.files); 77 + const resp = await createPost(agent, boardUri, input.body, { 78 + title: input.title, 86 79 attachments, 87 80 }); 81 + return resp; 82 + }, 83 + onSuccess: (resp) => { 84 + // Constellation lags a few seconds behind the PDS write. Wait 85 + // before invalidating or we'll refetch before the index is fresh. 86 + setTimeout(() => { 87 + queryClient.invalidateQueries( 88 + boardThreadsInfiniteQuery(bbs.identity.did, board.slug), 89 + ); 90 + }, 1500); 88 91 setTitle(""); 89 92 setBody(""); 90 93 setFiles([]); 91 - setTimeout(() => revalidator.revalidate(), 1500); 92 94 const { did, rkey } = parseAtUri(resp.data.uri); 93 95 navigate(`/bbs/${handle}/thread/${did}/${rkey}`); 94 - } catch (err: unknown) { 95 - console.error("createPost failed:", err); 96 - alert(`Could not post: ${err instanceof Error ? err.message : err}`); 97 - } finally { 98 - setPosting(false); 99 - } 96 + }, 97 + onError: (error) => { 98 + alert( 99 + `Could not post: ${error instanceof Error ? error.message : error}`, 100 + ); 101 + }, 102 + }); 103 + 104 + function onCreate(event: SyntheticEvent) { 105 + event.preventDefault(); 106 + if (createThreadMutation.isPending) return; 107 + createThreadMutation.mutate({ 108 + title: title.trim(), 109 + body: body.trim(), 110 + files, 111 + }); 100 112 } 101 113 102 114 return ( ··· 123 135 bodyMaxLength={limits.POST_BODY} 124 136 files={files} 125 137 onFilesChange={setFiles} 126 - posting={posting} 138 + posting={createThreadMutation.isPending} 127 139 /> 128 140 </details> 129 141 )} ··· 150 162 )} 151 163 </div> 152 164 153 - {cursor && ( 165 + {hasNextPage && ( 154 166 <div className="mt-6 text-center"> 155 167 <button 156 - onClick={loadMore} 157 - disabled={loadingMore} 168 + onClick={() => fetchNextPage()} 169 + disabled={isFetchingNextPage} 158 170 className="text-neutral-400 hover:text-neutral-300" 159 171 > 160 - {loadingMore ? "loading..." : "next page →"} 172 + {isFetchingNextPage ? "loading..." : "next page →"} 161 173 </button> 162 174 </div> 163 175 )}
+52 -63
web/src/pages/Dashboard.tsx
··· 1 - import { Await, useRevalidator } from "react-router-dom"; 2 - import { Suspense, useEffect, useMemo, useState } from "react"; 3 - import { useAuth } from "../lib/auth"; 1 + import { useMemo, useState } from "react"; 2 + import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; 3 + import { useAuth, type AuthUser } from "../lib/auth"; 4 4 import { deleteBBS } from "../lib/deletebbs"; 5 - import { useDiscovery } from "../hooks/useDiscovery"; 6 5 import { usePageTitle } from "../hooks/usePageTitle"; 6 + import { 7 + activityQuery, 8 + discoveryQuery, 9 + homeSysopQuery, 10 + myThreadsQuery, 11 + pinsQuery, 12 + } from "../lib/queries"; 13 + import { queryClient } from "../lib/queryClient"; 14 + import { invalidateAllBBSCaches } from "../lib/bbs"; 7 15 import DialBBS, { 8 16 bbsToSuggestion, 9 17 type Suggestion, ··· 12 20 import MyThreadList from "../components/dashboard/MyThreadList"; 13 21 import ActivityList from "../components/dashboard/ActivityList"; 14 22 import BBSPanel from "../components/dashboard/BBSPanel"; 15 - import type { ActivityItem, PinnedBBS, MyThread } from "../router/loaders"; 16 - import type { AuthUser } from "../lib/auth"; 17 - 18 - export interface DashboardData { 19 - user: AuthUser; 20 - hasBBS: boolean; 21 - bbsName: string | null; 22 - pins: Promise<PinnedBBS[]>; 23 - threads: Promise<MyThread[]>; 24 - activity: Promise<ActivityItem[]>; 25 - } 26 23 27 24 type Tab = "inbox" | "threads" | "pinned" | "bbs"; 28 25 ··· 31 28 const TAB_STYLE_INACTIVE = 32 29 "py-2 border-b-2 text-neutral-400 hover:text-neutral-300 border-transparent whitespace-nowrap"; 33 30 34 - export default function Dashboard({ 35 - user, 36 - hasBBS, 37 - bbsName, 38 - pins: pinsPromise, 39 - threads: threadsPromise, 40 - activity: activityPromise, 41 - }: DashboardData) { 31 + interface DashboardProps { 32 + user: AuthUser; 33 + } 34 + 35 + export default function Dashboard({ user }: DashboardProps) { 42 36 const { agent } = useAuth(); 43 - const revalidator = useRevalidator(); 44 - const discoveredBBSes = useDiscovery(); 45 37 const [tab, setTab] = useState<Tab>("inbox"); 46 - const [pins, setPins] = useState<PinnedBBS[]>([]); 47 38 usePageTitle("atbbs"); 48 39 49 - useEffect(() => { 50 - pinsPromise.then(setPins); 51 - }, [pinsPromise]); 40 + const { data: sysopInfo } = useSuspenseQuery(homeSysopQuery(user.did)); 41 + const { data: pins } = useSuspenseQuery(pinsQuery(user.pdsUrl, user.did)); 42 + const { data: threads } = useSuspenseQuery( 43 + myThreadsQuery(user.pdsUrl, user.did), 44 + ); 45 + const { data: activity } = useSuspenseQuery( 46 + activityQuery(user.pdsUrl, user.did), 47 + ); 48 + const { data: discovered } = useSuspenseQuery(discoveryQuery()); 52 49 53 50 const suggestions = useMemo<Suggestion[]>(() => { 54 51 const pinnedDids = new Set(pins.map((pin) => pin.did)); 55 52 const fromPins = pins.map(bbsToSuggestion); 56 - const fromDiscovery = discoveredBBSes 53 + const fromDiscovery = discovered 57 54 .filter((bbs) => !pinnedDids.has(bbs.did)) 58 55 .slice(0, 5) 59 56 .map(bbsToSuggestion); 60 57 return [...fromPins, ...fromDiscovery]; 61 - }, [pins, discoveredBBSes]); 58 + }, [pins, discovered]); 59 + 60 + const deleteBBSMutation = useMutation({ 61 + mutationFn: async () => { 62 + if (!agent) throw new Error("Not signed in"); 63 + await deleteBBS(agent, user.did, user.pdsUrl); 64 + }, 65 + onSuccess: () => { 66 + queryClient.invalidateQueries(homeSysopQuery(user.did)); 67 + invalidateAllBBSCaches(); 68 + }, 69 + onError: (error: unknown) => { 70 + alert( 71 + error instanceof Error ? error.message : "Could not delete community.", 72 + ); 73 + }, 74 + }); 62 75 63 - async function handleDeleteBBS() { 64 - if (!agent) return; 76 + function handleDeleteBBS() { 65 77 if ( 66 78 !confirm( 67 79 "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.", 68 80 ) 69 81 ) 70 82 return; 71 - try { 72 - await deleteBBS(agent, user.did, user.pdsUrl); 73 - revalidator.revalidate(); 74 - } catch (error) { 75 - alert( 76 - error instanceof Error ? error.message : "Could not delete community.", 77 - ); 78 - } 83 + deleteBBSMutation.mutate(); 79 84 } 80 85 81 86 const tabs: { key: Tab; label: string }[] = [ ··· 85 90 { key: "bbs", label: "Community" }, 86 91 ]; 87 92 88 - const loadingFallback = <p className="text-neutral-400">loading...</p>; 89 - 90 93 return ( 91 94 <> 92 95 <div className="border-b border-neutral-800 mb-6 pb-4"> 93 - <DialBBS discovered={discoveredBBSes} suggestions={suggestions} /> 96 + <DialBBS discovered={discovered} suggestions={suggestions} /> 94 97 </div> 95 98 96 99 <div ··· 117 120 <p className="text-neutral-400 text-xs mb-4"> 118 121 Recent replies from other users. 119 122 </p> 120 - <Suspense fallback={loadingFallback}> 121 - <Await resolve={activityPromise}> 122 - {(items: ActivityItem[]) => ( 123 - <ActivityList items={items} userHandle={user.handle} /> 124 - )} 125 - </Await> 126 - </Suspense> 123 + <ActivityList items={activity} userHandle={user.handle} /> 127 124 </> 128 125 )} 129 126 ··· 132 129 <p className="text-neutral-400 text-xs mb-4"> 133 130 Threads you've posted across all communities. 134 131 </p> 135 - <Suspense fallback={loadingFallback}> 136 - <Await resolve={threadsPromise}> 137 - {(threads: MyThread[]) => <MyThreadList threads={threads} />} 138 - </Await> 139 - </Suspense> 132 + <MyThreadList threads={threads} /> 140 133 </> 141 134 )} 142 135 ··· 145 138 <p className="text-neutral-400 text-xs mb-4"> 146 139 Communities you've pinned for quick access. 147 140 </p> 148 - <Suspense fallback={loadingFallback}> 149 - <Await resolve={pinsPromise}> 150 - {(pins: PinnedBBS[]) => <PinnedList pins={pins} />} 151 - </Await> 152 - </Suspense> 141 + <PinnedList pins={pins} /> 153 142 </> 154 143 )} 155 144 ··· 159 148 Manage your community. 160 149 </p> 161 150 <BBSPanel 162 - hasBBS={hasBBS} 151 + hasBBS={sysopInfo.hasBBS} 163 152 userHandle={user.handle} 164 153 userDid={user.did} 165 - bbsName={bbsName} 154 + bbsName={sysopInfo.bbsName} 166 155 onDelete={handleDeleteBBS} 167 156 /> 168 157 </>
+5 -11
web/src/pages/Home.tsx
··· 1 - import { useLoaderData } from "react-router-dom"; 2 - import Dashboard, { type DashboardData } from "./Dashboard"; 1 + import { useAuth } from "../lib/auth"; 2 + import Dashboard from "./Dashboard"; 3 3 import LoggedOutHome from "./LoggedOutHome"; 4 - 5 - interface HomeLoaderData { 6 - user: DashboardData["user"] | null; 7 - } 8 4 9 5 export default function Home() { 10 - const data = useLoaderData() as HomeLoaderData; 11 - 12 - if (data.user) return <Dashboard {...(data as DashboardData)} />; 13 - 14 - return <LoggedOutHome />; 6 + const { status, user } = useAuth(); 7 + if (status === "loading") return null; 8 + return user ? <Dashboard user={user} /> : <LoggedOutHome />; 15 9 }
+3 -2
web/src/pages/LoggedOutHome.tsx
··· 1 1 import { useMemo, useState } from "react"; 2 2 import { Phone, Copy, Check } from "lucide-react"; 3 - import { useDiscovery } from "../hooks/useDiscovery"; 3 + import { useSuspenseQuery } from "@tanstack/react-query"; 4 4 import { usePageTitle } from "../hooks/usePageTitle"; 5 + import { discoveryQuery } from "../lib/queries"; 5 6 import DialBBS, { 6 7 bbsToSuggestion, 7 8 type Suggestion, ··· 9 10 import DiscoveryList from "../components/dashboard/DiscoveryList"; 10 11 11 12 export default function LoggedOutHome() { 12 - const discovered = useDiscovery(); 13 + const { data: discovered } = useSuspenseQuery(discoveryQuery()); 13 14 const suggestions = useMemo<Suggestion[]>( 14 15 () => discovered.map(bbsToSuggestion), 15 16 [discovered],
+33 -27
web/src/pages/News.tsx
··· 1 - import { 2 - useLocation, 3 - useNavigate, 4 - useParams, 5 - useRouteLoaderData, 6 - } from "react-router-dom"; 1 + import { useNavigate, useParams } from "react-router-dom"; 2 + import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; 7 3 import { useAuth } from "../lib/auth"; 8 4 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 9 5 import { usePageTitle } from "../hooks/usePageTitle"; 10 6 import { POST } from "../lib/lexicon"; 11 7 import { deleteRecord } from "../lib/writes"; 12 - import { invalidateBBSCache, type NewsPost } from "../lib/bbs"; 13 - import type { BBSLoaderData } from "../router/loaders"; 8 + import { bbsQuery, newsQuery } from "../lib/queries"; 9 + import { queryClient } from "../lib/queryClient"; 10 + import type { NewsPost } from "../lib/bbs"; 14 11 import NewsCard from "../components/post/NewsCard"; 15 12 16 13 export default function NewsPage() { 17 14 const { handle, tid } = useParams(); 18 - const { bbs } = useRouteLoaderData("bbs") as BBSLoaderData; 19 15 const { user, agent } = useAuth(); 20 16 const navigate = useNavigate(); 21 17 22 - // Fallback for posts that were just created but haven't made it into the 23 - // cached BBS loader data yet. 24 - const stateItem = (useLocation().state as { pendingNewsItem?: NewsPost }) 25 - ?.pendingNewsItem; 26 - const item = 27 - bbs.news.find((news) => news.rkey === tid) ?? 28 - (stateItem?.rkey === tid ? stateItem : undefined); 18 + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); 19 + const { data: news } = useSuspenseQuery(newsQuery(bbs.identity.did)); 20 + const item = news.find((n) => n.rkey === tid); 29 21 30 22 useBreadcrumb( 31 23 [ ··· 38 30 item ? `${item.title} — ${bbs.site.name}` : `News — ${bbs.site.name}`, 39 31 ); 40 32 41 - if (!item) { 42 - return <p className="text-neutral-400">News post not found.</p>; 43 - } 44 - 45 33 const isSysop = !!(user && user.did === bbs.identity.did); 46 34 47 - async function onDelete() { 48 - if (!agent || !tid) return; 49 - if (!confirm("Delete this news post?")) return; 50 - await deleteRecord(agent, POST, tid); 51 - invalidateBBSCache(); 52 - navigate(`/bbs/${handle}`, { state: { deletedNewsRkey: tid } }); 35 + const deleteNewsMutation = useMutation({ 36 + mutationFn: async () => { 37 + if (!agent || !tid) throw new Error("Not signed in"); 38 + await deleteRecord(agent, POST, tid); 39 + }, 40 + onSuccess: () => { 41 + queryClient.setQueryData<NewsPost[]>( 42 + newsQuery(bbs.identity.did).queryKey, 43 + (prev) => (prev ?? []).filter((n) => n.rkey !== tid), 44 + ); 45 + navigate(`/bbs/${handle}`); 46 + }, 47 + onError: (error) => { 48 + alert( 49 + `Could not delete: ${error instanceof Error ? error.message : error}`, 50 + ); 51 + }, 52 + }); 53 + 54 + if (!item) { 55 + return <p className="text-neutral-400">News post not found.</p>; 53 56 } 54 57 55 58 return ( ··· 59 62 pds={bbs.identity.pds ?? ""} 60 63 did={bbs.identity.did} 61 64 isSysop={isSysop} 62 - onDelete={onDelete} 65 + onDelete={() => { 66 + if (!confirm("Delete this news post?")) return; 67 + deleteNewsMutation.mutate(); 68 + }} 63 69 /> 64 70 ); 65 71 }
+33 -22
web/src/pages/Profile.tsx
··· 1 - import { Suspense, useState } from "react"; 1 + import { useState } from "react"; 2 + import { useParams } from "react-router-dom"; 3 + import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; 2 4 import { MessageSquare } from "lucide-react"; 3 - import { Await, useLoaderData, useRevalidator } from "react-router-dom"; 4 5 import { useAuth } from "../lib/auth"; 5 6 import { usePageTitle } from "../hooks/usePageTitle"; 6 7 import { putProfile } from "../lib/writes"; 8 + import { myThreadsQuery, profileQuery } from "../lib/queries"; 9 + import { queryClient } from "../lib/queryClient"; 7 10 import ViewProfile from "../components/profile/ViewProfile"; 8 11 import EditProfile from "../components/profile/EditProfile"; 9 12 import MyThreadList from "../components/dashboard/MyThreadList"; 10 - import type { MyThread } from "../lib/mythreads"; 11 - import type { ProfileLoaderData } from "../router/loaders/profile"; 12 13 13 14 export default function Profile() { 14 - const { handle, profile, threads } = useLoaderData() as ProfileLoaderData; 15 + const { handle } = useParams(); 15 16 const { user, agent } = useAuth(); 16 - const revalidator = useRevalidator(); 17 - const isOwner = user?.handle === handle; 18 17 const [editing, setEditing] = useState(false); 18 + 19 + const { data: profile } = useSuspenseQuery(profileQuery(handle!)); 20 + const { data: threads } = useSuspenseQuery( 21 + myThreadsQuery(profile?.pdsUrl ?? "", profile?.did ?? ""), 22 + ); 23 + 19 24 usePageTitle(`${profile?.name ?? handle} — atbbs`); 20 25 21 - async function handleSave(name?: string, pronouns?: string, bio?: string) { 22 - if (!agent) return; 23 - await putProfile(agent, name, pronouns, bio); 24 - setEditing(false); 25 - revalidator.revalidate(); 26 - } 26 + const isOwner = user?.handle === handle; 27 + 28 + const saveProfileMutation = useMutation({ 29 + mutationFn: async (input: { 30 + name?: string; 31 + pronouns?: string; 32 + bio?: string; 33 + }) => { 34 + if (!agent) throw new Error("Not signed in"); 35 + await putProfile(agent, input.name, input.pronouns, input.bio); 36 + }, 37 + onSuccess: () => { 38 + queryClient.invalidateQueries(profileQuery(handle!)); 39 + setEditing(false); 40 + }, 41 + }); 27 42 28 43 if (editing) { 29 44 return ( ··· 31 46 initialName={profile?.name ?? ""} 32 47 initialPronouns={profile?.pronouns ?? ""} 33 48 initialBio={profile?.bio ?? ""} 34 - onSave={handleSave} 49 + onSave={(name, pronouns, bio) => 50 + saveProfileMutation.mutateAsync({ name, pronouns, bio }) 51 + } 35 52 onCancel={() => setEditing(false)} 36 53 /> 37 54 ); ··· 40 57 return ( 41 58 <> 42 59 <ViewProfile 43 - handle={handle} 60 + handle={handle!} 44 61 profile={profile} 45 62 isOwner={isOwner} 46 63 onEdit={() => setEditing(true)} ··· 49 66 <p className="text-xs text-neutral-400 uppercase tracking-wide mb-3 inline-flex items-center gap-1.5"> 50 67 <MessageSquare size={12} /> Recent Threads 51 68 </p> 52 - <Suspense fallback={<p className="text-neutral-400">loading...</p>}> 53 - <Await resolve={threads}> 54 - {(resolved: MyThread[]) => ( 55 - <MyThreadList threads={resolved.slice(0, 5)} /> 56 - )} 57 - </Await> 58 - </Suspense> 69 + <MyThreadList threads={threads.slice(0, 5)} /> 59 70 </div> 60 71 </> 61 72 );
+3 -5
web/src/pages/SysopCreate.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 - import { useNavigate, useLoaderData } from "react-router-dom"; 2 + import { useNavigate } from "react-router-dom"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { putBoard, putSite } from "../lib/writes"; 5 5 import { BOARD } from "../lib/lexicon"; ··· 11 11 import BoardRowEditor, { 12 12 type BoardRow, 13 13 } from "../components/form/BoardRowEditor"; 14 - import type { AuthUser } from "../lib/auth"; 15 14 16 15 export default function SysopCreate() { 17 - const { user } = useLoaderData() as { user: AuthUser }; 18 - const { agent } = useAuth(); 16 + const { user, agent } = useAuth(); 19 17 const navigate = useNavigate(); 20 18 21 19 const [name, setName] = useState(""); ··· 34 32 35 33 async function onSubmit(e: SyntheticEvent) { 36 34 e.preventDefault(); 37 - if (!agent) return; 35 + if (!agent || !user) return; 38 36 const cleanBoards = boards 39 37 .map((board) => ({ 40 38 slug: board.slug.trim(),
+9 -11
web/src/pages/SysopEdit.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 - import { useLoaderData, useNavigate } from "react-router-dom"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { useSuspenseQuery } from "@tanstack/react-query"; 3 4 import { useAuth } from "../lib/auth"; 4 5 import { putBoard, putSite } from "../lib/writes"; 5 6 import { BOARD } from "../lib/lexicon"; 6 7 import { makeAtUri, nowIso } from "../lib/util"; 7 8 import * as limits from "../lib/limits"; 8 9 import { usePageTitle } from "../hooks/usePageTitle"; 10 + import { bbsQuery } from "../lib/queries"; 9 11 import { Input, Textarea, Button } from "../components/form/Form"; 10 12 import BoardRowEditor, { 11 13 type BoardRow, 12 14 } from "../components/form/BoardRowEditor"; 13 - import type { BBS } from "../lib/bbs"; 14 - import type { AuthUser } from "../lib/auth"; 15 - 16 - interface LoaderData { 17 - user: AuthUser; 18 - bbs: BBS; 19 - } 20 15 21 16 export default function SysopEdit() { 22 - const { user, bbs } = useLoaderData() as LoaderData; 23 - const { agent } = useAuth(); 17 + const { user, agent } = useAuth(); 24 18 const navigate = useNavigate(); 19 + 20 + // requireAuthLoader has already redirected unauthenticated users, so 21 + // `user` is non-null at render time. 22 + const { data: bbs } = useSuspenseQuery(bbsQuery(user!.handle)); 25 23 26 24 const [name, setName] = useState(bbs.site.name); 27 25 const [description, setDescription] = useState(bbs.site.description); ··· 39 37 40 38 async function onSubmit(e: SyntheticEvent) { 41 39 e.preventDefault(); 42 - if (!agent || !name.trim()) return; 40 + if (!agent || !user || !name.trim()) return; 43 41 const cleanBoards = boards 44 42 .map((board) => ({ 45 43 slug: board.slug.trim(),
+71 -48
web/src/pages/SysopModerate.tsx
··· 1 1 import { useState } from "react"; 2 - import { useLoaderData, useRevalidator } from "react-router-dom"; 2 + import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { resolveIdentity } from "../lib/atproto"; 5 5 import { BAN, HIDE } from "../lib/lexicon"; 6 - import { invalidateBBSCache } from "../lib/bbs"; 6 + import { invalidateAllBBSCaches } from "../lib/bbs"; 7 + import { bbsQuery, sysopModerationQuery } from "../lib/queries"; 8 + import { queryClient } from "../lib/queryClient"; 7 9 import HandleInput from "../components/form/HandleInput"; 8 10 import { Button } from "../components/form/Form"; 9 11 import { usePageTitle } from "../hooks/usePageTitle"; 10 12 import { createBan, createHide, deleteRecord } from "../lib/writes"; 11 - import type { BBS } from "../lib/bbs"; 12 - import type { AuthUser } from "../lib/auth"; 13 - import type { HiddenInfo } from "../router/loaders"; 14 - 15 - interface LoaderData { 16 - user: AuthUser; 17 - bbs: BBS; 18 - banRkeys: Record<string, string>; 19 - bannedHandles: Record<string, string>; 20 - hideRkeys: Record<string, string>; 21 - hidden: HiddenInfo[]; 22 - } 23 13 24 14 export default function SysopModerate() { 25 - const { bbs, banRkeys, bannedHandles, hideRkeys, hidden } = 26 - useLoaderData() as LoaderData; 27 - const { agent } = useAuth(); 28 - const revalidator = useRevalidator(); 15 + const { user, agent } = useAuth(); 29 16 const [identifier, setIdentifier] = useState(""); 30 17 const [hideUri, setHideUri] = useState(""); 31 18 usePageTitle("Moderate community — atbbs"); 32 19 33 - async function ban() { 34 - if (!agent) return; 35 - let id = identifier.trim(); 20 + // requireAuthLoader guarantees user is present at render time. 21 + const { data: bbs } = useSuspenseQuery(bbsQuery(user!.handle)); 22 + const { data: moderation } = useSuspenseQuery( 23 + sysopModerationQuery(user!.pdsUrl, user!.did), 24 + ); 25 + const { banRkeys, bannedHandles, hideRkeys, hidden } = moderation; 26 + 27 + function refreshModeration() { 28 + queryClient.invalidateQueries( 29 + sysopModerationQuery(user!.pdsUrl, user!.did), 30 + ); 31 + invalidateAllBBSCaches(); 32 + } 33 + 34 + const banMutation = useMutation({ 35 + mutationFn: async (identifier: string) => { 36 + if (!agent) throw new Error("Not signed in"); 37 + let did = identifier; 38 + if (!did.startsWith("did:")) did = (await resolveIdentity(did)).did; 39 + await createBan(agent, did); 40 + }, 41 + onSuccess: () => { 42 + setIdentifier(""); 43 + refreshModeration(); 44 + }, 45 + onError: (err) => 46 + alert(`Could not ban: ${err instanceof Error ? err.message : err}`), 47 + }); 48 + 49 + const unbanMutation = useMutation({ 50 + mutationFn: async (rkey: string) => { 51 + if (!agent) throw new Error("Not signed in"); 52 + await deleteRecord(agent, BAN, rkey); 53 + }, 54 + onSuccess: refreshModeration, 55 + }); 56 + 57 + const hideMutation = useMutation({ 58 + mutationFn: async (uri: string) => { 59 + if (!agent) throw new Error("Not signed in"); 60 + await createHide(agent, uri); 61 + }, 62 + onSuccess: () => { 63 + setHideUri(""); 64 + refreshModeration(); 65 + }, 66 + }); 67 + 68 + const unhideMutation = useMutation({ 69 + mutationFn: async (rkey: string) => { 70 + if (!agent) throw new Error("Not signed in"); 71 + await deleteRecord(agent, HIDE, rkey); 72 + }, 73 + onSuccess: refreshModeration, 74 + }); 75 + 76 + function ban() { 77 + const id = identifier.trim(); 36 78 if (!id) return; 37 - if (!id.startsWith("did:")) { 38 - try { 39 - id = (await resolveIdentity(id)).did; 40 - } catch { 41 - alert("Could not resolve handle."); 42 - return; 43 - } 44 - } 45 - await createBan(agent, id); 46 - setIdentifier(""); 47 - revalidator.revalidate(); 79 + banMutation.mutate(id); 48 80 } 49 81 50 - async function unban(rkey: string) { 51 - if (!agent) return; 82 + function unban(rkey: string) { 52 83 if (!confirm("Unban this user?")) return; 53 - await deleteRecord(agent, BAN, rkey); 54 - invalidateBBSCache(); 55 - revalidator.revalidate(); 84 + unbanMutation.mutate(rkey); 56 85 } 57 86 58 - async function hide() { 59 - if (!agent) return; 87 + function hide() { 60 88 const u = hideUri.trim(); 61 89 if (!u.startsWith("at://")) { 62 90 alert("Enter a valid AT-URI."); 63 91 return; 64 92 } 65 - await createHide(agent, u); 66 - setHideUri(""); 67 - revalidator.revalidate(); 93 + hideMutation.mutate(u); 68 94 } 69 95 70 - async function unhide(rkey: string) { 71 - if (!agent) return; 96 + function unhide(rkey: string) { 72 97 if (!confirm("Unhide this post?")) return; 73 - await deleteRecord(agent, HIDE, rkey); 74 - invalidateBBSCache(); 75 - revalidator.revalidate(); 98 + unhideMutation.mutate(rkey); 76 99 } 77 100 78 101 return ( 79 102 <> 80 103 <h1 className="text-lg text-neutral-200 mb-1">Moderate community</h1> 81 104 <p className="text-neutral-400 mb-6"> 82 - Manage banned users and hidden posts. 105 + Manage banned users and hidden posts for {bbs.site.name}. 83 106 </p> 84 107 85 108 <div className="space-y-8">
+284 -112
web/src/pages/Thread.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 - import { 3 - useLoaderData, 4 - useNavigate, 5 - useRevalidator, 6 - useRouteLoaderData, 7 - } from "react-router-dom"; 2 + import { useNavigate, useParams } from "react-router-dom"; 3 + import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; 8 4 import { useAuth } from "../lib/auth"; 9 5 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 10 6 import { usePageTitle } from "../hooks/usePageTitle"; 11 7 import { useThreadReplies } from "../hooks/useThreadReplies"; 12 - import { BOARD, POST } from "../lib/lexicon"; 13 - import { makeAtUri, parseAtUri } from "../lib/util"; 8 + import { BAN, BOARD, HIDE, POST } from "../lib/lexicon"; 9 + import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; 14 10 import * as limits from "../lib/limits"; 15 11 import { 16 12 createBan, ··· 19 15 deleteRecord, 20 16 uploadAttachments, 21 17 } from "../lib/writes"; 22 - import type { BBSLoaderData, ThreadObj } from "../router/loaders"; 18 + import { 19 + bbsModerationQuery, 20 + bbsQuery, 21 + threadPageQuery, 22 + threadRefsQuery, 23 + threadRootQuery, 24 + } from "../lib/queries"; 25 + import { queryClient } from "../lib/queryClient"; 26 + import { threadUriFor } from "../lib/thread"; 27 + import { REPLIES_PER_PAGE, refToUri } from "../lib/replies"; 28 + import { invalidateAllBBSCaches } from "../lib/bbs"; 29 + import type { BacklinkRef } from "../lib/atproto"; 30 + import type { ReplyPage } from "../lib/thread"; 31 + import type { BBS } from "../lib/bbs"; 23 32 import PageNav from "../components/nav/PageNav"; 24 33 import ReplyCard, { type Reply } from "../components/post/ReplyCard"; 25 34 import ComposeForm from "../components/form/ComposeForm"; 26 35 import ThreadCard from "../components/post/ThreadCard"; 27 36 28 - interface LoaderData { 29 - handle: string; 30 - bbs: BBSLoaderData["bbs"]; 31 - thread: ThreadObj; 32 - allRefs: { did: string; collection: string; rkey: string }[]; 33 - } 34 - 35 - /** 36 - * Outer wrapper: re-keys the inner page on thread URI so navigating between 37 - * threads gives us a fresh component instance (and fresh hook state). Without 38 - * this, react-router reuses the same Thread component on param change and 39 - * state from the previous thread (page index, optimistic adds) bleeds in. 40 - */ 41 - export default function ThreadRoute() { 42 - const loaded = useLoaderData() as LoaderData; 43 - return <ThreadPage key={loaded.thread.uri} loaded={loaded} />; 44 - } 45 - 46 - function ThreadPage({ loaded }: { loaded: LoaderData }) { 47 - const { bbs } = useRouteLoaderData("bbs") as BBSLoaderData; 48 - const { handle, thread } = loaded; 37 + export default function ThreadPage() { 38 + const { handle, did, tid } = useParams(); 39 + const threadUri = threadUriFor(did!, tid!); 49 40 const { user, agent } = useAuth(); 50 - const revalidator = useRevalidator(); 51 41 const navigate = useNavigate(); 52 42 43 + const { data: bbs } = useSuspenseQuery(bbsQuery(handle!)); 44 + const { data: thread } = useSuspenseQuery(threadRootQuery(did!, tid!)); 45 + const { data: moderation } = useSuspenseQuery( 46 + bbsModerationQuery(bbs.identity.pds ?? "", bbs.identity.did), 47 + ); 53 48 const { 54 49 page, 55 50 setPage, 56 51 totalPages, 57 - replies, 58 - loading: loadingPage, 59 52 refs, 60 - replyCache, 53 + replies, 54 + parentReplies, 61 55 scrollToReply, 62 - addOptimisticReply, 63 - removeReply, 64 - } = useThreadReplies(loaded); 56 + } = useThreadReplies(threadUri); 57 + 58 + const isSysop = !!(user && user.did === bbs.identity.did); 59 + const threadHidden = 60 + !isSysop && 61 + (moderation.bannedDids.has(thread.did) || 62 + moderation.hiddenUris.has(thread.uri)); 63 + const visibleReplies = isSysop 64 + ? replies 65 + : replies.filter( 66 + (reply) => 67 + !moderation.bannedDids.has(reply.did) && 68 + !moderation.hiddenUris.has(reply.uri), 69 + ); 65 70 66 71 const [body, setBody] = useState(""); 67 72 const [files, setFiles] = useState<File[]>([]); ··· 69 74 uri: string; 70 75 handle: string; 71 76 } | null>(null); 72 - const [posting, setPosting] = useState(false); 73 77 74 78 usePageTitle(`${thread.title} — ${bbs.site.name}`); 75 - useBreadcrumb(buildBreadcrumb(bbs, thread, handle), [bbs, thread, handle]); 79 + useBreadcrumb(buildBreadcrumb(bbs, thread.title, thread.boardSlug, handle!), [ 80 + bbs, 81 + thread, 82 + handle, 83 + ]); 76 84 77 - const isSysop = user && user.did === bbs.identity.did; 85 + // --- Mutations --- 78 86 79 - async function onReply(e: SyntheticEvent) { 80 - e.preventDefault(); 81 - if (!agent || !user) return; 82 - setPosting(true); 83 - try { 84 - const threadUri = makeAtUri(thread.did, POST, thread.rkey); 85 - const attachments = await uploadAttachments(agent, files); 87 + const createReplyMutation = useMutation({ 88 + mutationFn: async (input: { 89 + body: string; 90 + parent: string | null; 91 + files: File[]; 92 + }) => { 93 + if (!agent || !user) throw new Error("Not signed in"); 86 94 const boardUri = makeAtUri(bbs.identity.did, BOARD, thread.boardSlug); 87 - const resp = await createPost(agent, boardUri, body.trim(), { 95 + const attachments = await uploadAttachments(agent, input.files); 96 + const resp = await createPost(agent, boardUri, input.body, { 88 97 root: threadUri, 89 - parent: replyingTo?.uri ?? undefined, 98 + parent: input.parent ?? undefined, 90 99 attachments, 91 100 }); 92 - addOptimisticReply({ 101 + return { resp, input, attachments }; 102 + }, 103 + onSuccess: ({ resp, input, attachments }) => { 104 + if (!user) return; 105 + const { did: newDid, rkey: newRkey } = parseAtUri(resp.data.uri); 106 + const newRef: BacklinkRef = { 107 + did: newDid, 108 + collection: POST, 109 + rkey: newRkey, 110 + }; 111 + const newReply: Reply = { 93 112 uri: resp.data.uri, 94 - did: parseAtUri(resp.data.uri).did, 95 - rkey: parseAtUri(resp.data.uri).rkey, 113 + did: newDid, 114 + rkey: newRkey, 96 115 handle: user.handle, 97 116 pds: user.pdsUrl, 98 - body: body.trim(), 99 - createdAt: new Date().toISOString(), 100 - parent: replyingTo?.uri ?? null, 117 + body: input.body, 118 + createdAt: nowIso(), 119 + parent: input.parent, 101 120 attachments: attachments as Reply["attachments"], 102 - }); 121 + }; 122 + 123 + const updatedRefs = appendRef(threadUri, newRef); 124 + seedPageWithReply(threadUri, updatedRefs, newReply); 125 + 103 126 setBody(""); 104 127 setFiles([]); 105 128 setReplyingTo(null); 106 - } catch { 107 - alert("Could not post reply."); 108 - } finally { 109 - setPosting(false); 110 - } 129 + 130 + const newLastPage = Math.max( 131 + 1, 132 + Math.ceil(updatedRefs.length / REPLIES_PER_PAGE), 133 + ); 134 + if (page !== newLastPage) setPage(newLastPage); 135 + }, 136 + onError: (err) => 137 + alert( 138 + `Could not post reply: ${err instanceof Error ? err.message : err}`, 139 + ), 140 + }); 141 + 142 + const deleteReplyMutation = useMutation({ 143 + mutationFn: async (reply: Reply) => { 144 + if (!agent) throw new Error("Not signed in"); 145 + await deleteRecord(agent, POST, reply.rkey); 146 + return reply; 147 + }, 148 + onSuccess: (reply) => { 149 + removeRefAndReply(threadUri, reply.uri, page); 150 + }, 151 + onError: (err) => 152 + alert(`Could not delete: ${err instanceof Error ? err.message : err}`), 153 + }); 154 + 155 + const deleteThreadMutation = useMutation({ 156 + mutationFn: async () => { 157 + if (!agent) throw new Error("Not signed in"); 158 + await deleteRecord(agent, POST, thread.rkey); 159 + }, 160 + onSuccess: () => navigate(`/bbs/${handle}`), 161 + onError: (err) => 162 + alert(`Could not delete: ${err instanceof Error ? err.message : err}`), 163 + }); 164 + 165 + const moderationMutationDefaults = { onSuccess: invalidateAllBBSCaches }; 166 + 167 + const banMutation = useMutation({ 168 + ...moderationMutationDefaults, 169 + mutationFn: async (banDid: string) => { 170 + if (!agent) throw new Error("Not signed in"); 171 + await createBan(agent, banDid); 172 + }, 173 + }); 174 + 175 + const unbanMutation = useMutation({ 176 + ...moderationMutationDefaults, 177 + mutationFn: async (rkey: string) => { 178 + if (!agent) throw new Error("Not signed in"); 179 + await deleteRecord(agent, BAN, rkey); 180 + }, 181 + }); 182 + 183 + const hideMutation = useMutation({ 184 + ...moderationMutationDefaults, 185 + mutationFn: async (uri: string) => { 186 + if (!agent) throw new Error("Not signed in"); 187 + await createHide(agent, uri); 188 + }, 189 + }); 190 + 191 + const unhideMutation = useMutation({ 192 + ...moderationMutationDefaults, 193 + mutationFn: async (rkey: string) => { 194 + if (!agent) throw new Error("Not signed in"); 195 + await deleteRecord(agent, HIDE, rkey); 196 + }, 197 + }); 198 + 199 + // --- Handlers --- 200 + 201 + function onReply(event: SyntheticEvent) { 202 + event.preventDefault(); 203 + if (createReplyMutation.isPending) return; 204 + createReplyMutation.mutate({ 205 + body: body.trim(), 206 + parent: replyingTo?.uri ?? null, 207 + files, 208 + }); 111 209 } 112 210 113 - async function onDeleteThread() { 114 - if (!agent) return; 211 + function onDeleteThread() { 115 212 if (!confirm("Delete this thread?")) return; 116 - await deleteRecord(agent, POST, thread.rkey); 117 - navigate(`/bbs/${handle}`); 213 + deleteThreadMutation.mutate(); 118 214 } 119 215 120 - async function onDeleteReply(reply: Reply) { 121 - if (!agent) return; 216 + function onDeleteReply(reply: Reply) { 122 217 if (!confirm("Delete this reply?")) return; 123 - try { 124 - await deleteRecord(agent, POST, reply.rkey); 125 - } catch (e: unknown) { 126 - console.error("deleteRecord failed:", e); 127 - alert(`Could not delete: ${e instanceof Error ? e.message : e}`); 128 - return; 129 - } 130 - removeReply(reply.uri); 131 - revalidator.revalidate(); 218 + deleteReplyMutation.mutate(reply); 132 219 } 133 220 134 - async function onBan(banDid: string) { 135 - if (!agent) return; 221 + function onBan(banDid: string) { 136 222 if (!confirm("Ban this user from your community?")) return; 137 - await createBan(agent, banDid); 138 - revalidator.revalidate(); 223 + banMutation.mutate(banDid); 224 + } 225 + 226 + function onUnban(rkey: string) { 227 + if (!confirm("Unban this user?")) return; 228 + unbanMutation.mutate(rkey); 139 229 } 140 230 141 - async function onHide(uri: string) { 142 - if (!agent) return; 231 + function onHide(uri: string) { 143 232 if (!confirm("Hide this post?")) return; 144 - await createHide(agent, uri); 145 - revalidator.revalidate(); 233 + hideMutation.mutate(uri); 234 + } 235 + 236 + function onUnhide(rkey: string) { 237 + if (!confirm("Unhide this post?")) return; 238 + unhideMutation.mutate(rkey); 239 + } 240 + 241 + if (threadHidden) { 242 + return ( 243 + <p className="text-neutral-400 py-16 text-center"> 244 + This thread has been hidden by the sysop. 245 + </p> 246 + ); 146 247 } 147 248 148 249 return ( ··· 151 252 thread={thread} 152 253 userDid={user?.did} 153 254 sysopDid={bbs.identity.did} 255 + banRkey={moderation.banRkeys[thread.did] ?? null} 256 + hideRkey={moderation.hideRkeys[thread.uri] ?? null} 154 257 onDelete={onDeleteThread} 155 258 onBan={() => onBan(thread.did)} 259 + onUnban={onUnban} 156 260 onHide={() => onHide(thread.uri)} 261 + onUnhide={onUnhide} 157 262 /> 158 263 159 264 {totalPages > 1 && ( ··· 161 266 )} 162 267 163 268 <div className="space-y-2 mt-4"> 164 - {loadingPage ? ( 165 - <p className="text-neutral-400">loading...</p> 166 - ) : replies.length === 0 && !user ? ( 269 + {visibleReplies.length === 0 && !user ? ( 167 270 <p className="text-neutral-400">No replies yet.</p> 168 271 ) : ( 169 - replies.map((reply) => ( 170 - <ReplyCard 171 - key={reply.uri} 172 - reply={reply} 173 - userDid={user?.did ?? ""} 174 - sysopDid={bbs.identity.did} 175 - parentPost={reply.parent ? replyCache[reply.parent] : undefined} 176 - onReplyTo={() => 177 - setReplyingTo({ uri: reply.uri, handle: reply.handle }) 178 - } 179 - onParentClick={ 180 - reply.parent ? () => scrollToReply(reply.parent!) : undefined 181 - } 182 - onDelete={() => onDeleteReply(reply)} 183 - onBan={() => onBan(reply.did)} 184 - onHide={() => onHide(reply.uri)} 185 - /> 186 - )) 272 + visibleReplies.map((reply) => { 273 + const parentReply = reply.parent 274 + ? parentReplies[reply.parent] 275 + : null; 276 + const parentHidden = 277 + !!parentReply && 278 + !isSysop && 279 + (moderation.bannedDids.has(parentReply.did) || 280 + moderation.hiddenUris.has(parentReply.uri)); 281 + return ( 282 + <ReplyCard 283 + key={reply.uri} 284 + reply={reply} 285 + userDid={user?.did ?? ""} 286 + sysopDid={bbs.identity.did} 287 + parentPost={ 288 + parentHidden ? undefined : (parentReply ?? undefined) 289 + } 290 + banRkey={moderation.banRkeys[reply.did] ?? null} 291 + hideRkey={moderation.hideRkeys[reply.uri] ?? null} 292 + onReplyTo={() => 293 + setReplyingTo({ uri: reply.uri, handle: reply.handle }) 294 + } 295 + onParentClick={ 296 + reply.parent ? () => scrollToReply(reply.parent!) : undefined 297 + } 298 + onDelete={() => onDeleteReply(reply)} 299 + onBan={() => onBan(reply.did)} 300 + onUnban={onUnban} 301 + onHide={() => onHide(reply.uri)} 302 + onUnhide={onUnhide} 303 + /> 304 + ); 305 + }) 187 306 )} 188 307 </div> 189 308 ··· 207 326 replyingTo={replyingTo} 208 327 onClearReplyTo={() => setReplyingTo(null)} 209 328 submitLabel="reply" 210 - posting={posting} 329 + posting={createReplyMutation.isPending} 211 330 /> 212 331 )} 213 332 </> 214 333 ); 215 334 } 216 335 336 + // --- Cache-update helpers --- 337 + 338 + function getRefs(threadUri: string): BacklinkRef[] { 339 + const key = threadRefsQuery(threadUri).queryKey; 340 + return queryClient.getQueryData<BacklinkRef[]>(key) ?? []; 341 + } 342 + 343 + function setRefs(threadUri: string, refs: BacklinkRef[]) { 344 + queryClient.setQueryData(threadRefsQuery(threadUri).queryKey, refs); 345 + } 346 + 347 + function appendRef(threadUri: string, newRef: BacklinkRef): BacklinkRef[] { 348 + const updated = [...getRefs(threadUri), newRef]; 349 + setRefs(threadUri, updated); 350 + return updated; 351 + } 352 + 353 + function pageSlice(refs: BacklinkRef[], page: number): BacklinkRef[] { 354 + const start = (page - 1) * REPLIES_PER_PAGE; 355 + return refs.slice(start, start + REPLIES_PER_PAGE); 356 + } 357 + 358 + function seedPageWithReply( 359 + threadUri: string, 360 + refs: BacklinkRef[], 361 + reply: Reply, 362 + ) { 363 + const newLastPage = Math.max(1, Math.ceil(refs.length / REPLIES_PER_PAGE)); 364 + const pageRefs = pageSlice(refs, newLastPage); 365 + const key = threadPageQuery(threadUri, newLastPage, pageRefs).queryKey; 366 + queryClient.setQueryData<ReplyPage>(key, (prev) => ({ 367 + replies: [...(prev?.replies ?? []), reply], 368 + parentReplies: prev?.parentReplies ?? {}, 369 + })); 370 + } 371 + 372 + function removeRefAndReply( 373 + threadUri: string, 374 + replyUri: string, 375 + currentPage: number, 376 + ) { 377 + const updatedRefs = getRefs(threadUri).filter( 378 + (ref) => refToUri(ref) !== replyUri, 379 + ); 380 + setRefs(threadUri, updatedRefs); 381 + const pageRefs = pageSlice(updatedRefs, currentPage); 382 + const key = threadPageQuery(threadUri, currentPage, pageRefs).queryKey; 383 + queryClient.setQueryData<ReplyPage>(key, (prev) => 384 + prev 385 + ? { ...prev, replies: prev.replies.filter((r) => r.uri !== replyUri) } 386 + : prev, 387 + ); 388 + } 389 + 217 390 function buildBreadcrumb( 218 - bbs: BBSLoaderData["bbs"], 219 - thread: ThreadObj, 391 + bbs: BBS, 392 + threadTitle: string, 393 + boardSlug: string, 220 394 handle: string, 221 395 ) { 222 - const board = bbs.site.boards.find( 223 - (board) => board.slug === thread.boardSlug, 224 - ); 396 + const board = bbs.site.boards.find((b) => b.slug === boardSlug); 225 397 return [ 226 398 { label: bbs.site.name, to: `/bbs/${handle}` }, 227 399 ...(board 228 400 ? [{ label: board.name, to: `/bbs/${handle}/board/${board.slug}` }] 229 401 : []), 230 - { label: thread.title }, 402 + { label: threadTitle }, 231 403 ]; 232 404 }
+21 -1
web/src/router/loaders/account.ts
··· 1 + import { redirect } from "react-router-dom"; 2 + import { NoBBSError } from "../../lib/bbs"; 3 + import { bbsQuery } from "../../lib/queries"; 4 + import { queryClient } from "../../lib/queryClient"; 1 5 import { requireAuth } from "./auth"; 2 6 7 + /** Loader for /account/create — just gates the route on auth. */ 3 8 export async function requireAuthLoader() { 4 - return { user: await requireAuth() }; 9 + await requireAuth(); 10 + return null; 11 + } 12 + 13 + /** Loader for /account/edit and /account/moderate — requires auth AND an 14 + * existing BBS. Warms the Query cache so the page's useSuspenseQuery 15 + * lands on fresh data with no flash. */ 16 + export async function requireSysopBBSLoader() { 17 + const user = await requireAuth(); 18 + try { 19 + await queryClient.ensureQueryData(bbsQuery(user.handle)); 20 + } catch (error) { 21 + if (error instanceof NoBBSError) throw redirect("/account/create"); 22 + throw error; 23 + } 24 + return null; 5 25 }
-24
web/src/router/loaders/bbs.ts
··· 1 - import type { LoaderFunctionArgs } from "react-router-dom"; 2 - import { resolveBBS, type BBS } from "../../lib/bbs"; 3 - import { getCurrentUser } from "../../lib/auth"; 4 - import { fetchPins, findPinRkey } from "../../lib/pins"; 5 - 6 - export async function bbsLoader({ params }: LoaderFunctionArgs) { 7 - const handle = params.handle!; 8 - const bbs = await resolveBBS(handle); 9 - 10 - let pinRkey: string | null = null; 11 - const user = getCurrentUser(); 12 - if (user) { 13 - const pins = await fetchPins(user.pdsUrl, user.did); 14 - pinRkey = findPinRkey(pins, bbs.identity.did); 15 - } 16 - 17 - return { handle, bbs, pinRkey }; 18 - } 19 - 20 - export type BBSLoaderData = { 21 - handle: string; 22 - bbs: BBS; 23 - pinRkey: string | null; 24 - };
+20 -35
web/src/router/loaders/board.ts web/src/lib/boardThreads.ts
··· 1 - import type { LoaderFunctionArgs } from "react-router-dom"; 2 - import { resolveBBS, type BBS } from "../../lib/bbs"; 1 + /** Build a page of thread summaries for a board, sorted by last activity. 2 + * 3 + * Scans recent board activity (threads + replies) from Constellation and 4 + * collects unique thread URIs in the order they appear. Since Constellation 5 + * returns newest posts first, the first time a thread URI appears is its 6 + * most recent activity — giving us bump order naturally. */ 7 + 3 8 import { 4 9 getAvatars, 5 10 getBacklinkCountsBatch, ··· 7 12 getRecordsBatch, 8 13 getRecordsByUri, 9 14 resolveIdentitiesBatch, 10 - } from "../../lib/atproto"; 11 - import { POST, BOARD } from "../../lib/lexicon"; 12 - import { makeAtUri, parseAtUri } from "../../lib/util"; 15 + } from "./atproto"; 16 + import { POST, BOARD } from "./lexicon"; 17 + import { makeAtUri, parseAtUri } from "./util"; 13 18 import { is } from "@atcute/lexicons/validations"; 14 - import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; 15 - import type { XyzAtbbsPost } from "../../lexicons"; 19 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 20 + import type { XyzAtbbsPost } from "../lexicons"; 16 21 17 22 export interface Participant { 18 23 did: string; ··· 33 38 participants: Participant[]; 34 39 } 35 40 41 + export interface ThreadPageResult { 42 + threads: ThreadItem[]; 43 + cursor: string | null; 44 + } 45 + 36 46 const MAX_SCANS = 4; 37 47 const PAGE_SIZE = 25; 38 48 39 - /** 40 - * Fetch threads for a board, sorted by last activity (bump order). 41 - * 42 - * Scans recent board activity (threads + replies) and collects unique 43 - * thread URIs in the order they appear. Since Constellation returns 44 - * newest posts first, the first time a thread URI appears is its most 45 - * recent activity — giving us bump order naturally. 46 - */ 47 49 export async function hydrateThreadPage( 48 - bbs: BBS, 50 + bbsDid: string, 49 51 slug: string, 50 52 cursor?: string, 51 - ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { 52 - const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); 53 + ): Promise<ThreadPageResult> { 54 + const boardUri = makeAtUri(bbsDid, BOARD, slug); 53 55 54 - // Phase 1: Scan board activity to find unique thread URIs and their posters. 55 - // Constellation returns newest-first, so first-seen activity per thread = most 56 - // recent, and Set insertion order preserves newest-first poster order. 57 56 const lastActivity = new Map<string, string>(); 58 57 const postersByThread = new Map<string, Set<string>>(); 59 58 let scanCursor = cursor; ··· 89 88 if (!scanCursor) break; 90 89 } 91 90 92 - // Phase 2: Fetch root post records for the thread URIs. 93 91 const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE); 94 92 const rootRecords = await getRecordsByUri(threadUris); 95 93 ··· 99 97 return value.title && !value.root; 100 98 }); 101 99 102 - // Phase 3: Resolve identities+avatars for every poster across all threads, 103 - // count replies, and build ThreadItems. 104 100 const allDids = new Set<string>(); 105 101 for (const record of validRoots) { 106 102 allDids.add(parseAtUri(record.uri).did); ··· 147 143 148 144 return { threads, cursor: scanCursor ?? null }; 149 145 } 150 - 151 - export async function boardLoader({ params }: LoaderFunctionArgs) { 152 - const handle = params.handle!; 153 - const slug = params.slug!; 154 - const bbs = await resolveBBS(handle); 155 - const board = bbs.site.boards.find((board) => board.slug === slug); 156 - if (!board) throw new Response("Board not found", { status: 404 }); 157 - 158 - const { threads, cursor } = await hydrateThreadPage(bbs, slug); 159 - return { handle, bbs, board, threads, cursor }; 160 - }
-32
web/src/router/loaders/home.ts
··· 1 - import { getRecord } from "../../lib/atproto"; 2 - import { ensureAuthReady, getCurrentUser } from "../../lib/auth"; 3 - import { fetchActivity } from "../../lib/activity"; 4 - import { fetchPins } from "../../lib/pins"; 5 - import { fetchMyThreads } from "../../lib/mythreads"; 6 - import { SITE } from "../../lib/lexicon"; 7 - 8 - export async function homeLoader() { 9 - await ensureAuthReady(); 10 - const user = getCurrentUser(); 11 - if (!user) return { user: null }; 12 - 13 - let hasBBS = false; 14 - let bbsName: string | null = null; 15 - try { 16 - const record = await getRecord(user.did, SITE, "self"); 17 - hasBBS = true; 18 - const value = record.value as { name?: string }; 19 - bbsName = value.name ?? null; 20 - } catch { 21 - // no site record 22 - } 23 - 24 - return { 25 - user, 26 - hasBBS, 27 - bbsName, 28 - activity: fetchActivity(user.did, user.pdsUrl), 29 - pins: fetchPins(user.pdsUrl, user.did), 30 - threads: fetchMyThreads(user.pdsUrl, user.did), 31 - }; 32 - }
+1 -10
web/src/router/loaders/index.ts
··· 1 - export { homeLoader } from "./home"; 2 - export { bbsLoader, type BBSLoaderData } from "./bbs"; 3 - export { profileLoader } from "./profile"; 4 - export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board"; 5 - export { threadLoader, type ThreadObj } from "./thread"; 6 - export { requireAuthLoader } from "./account"; 7 - export type { ActivityItem } from "../../lib/activity"; 8 - export type { PinnedBBS } from "../../lib/pins"; 9 - export type { MyThread } from "../../lib/mythreads"; 10 - export { sysopEditLoader, sysopModerateLoader, type HiddenInfo } from "./sysop"; 1 + export { requireAuthLoader, requireSysopBBSLoader } from "./account";
-21
web/src/router/loaders/profile.ts
··· 1 - import type { LoaderFunctionArgs } from "react-router-dom"; 2 - import { fetchProfile, type Profile } from "../../lib/profile"; 3 - import { fetchMyThreads, type MyThread } from "../../lib/mythreads"; 4 - 5 - export async function profileLoader({ params }: LoaderFunctionArgs) { 6 - const handle = params.handle!; 7 - const profile = await fetchProfile(handle); 8 - 9 - let threads: Promise<MyThread[]> = Promise.resolve([]); 10 - if (profile) { 11 - threads = fetchMyThreads(profile.pdsUrl, profile.did); 12 - } 13 - 14 - return { handle, profile, threads }; 15 - } 16 - 17 - export type ProfileLoaderData = { 18 - handle: string; 19 - profile: Profile | null; 20 - threads: Promise<MyThread[]>; 21 - };
+23 -40
web/src/router/loaders/sysop.ts web/src/lib/sysopModeration.ts
··· 1 - import { redirect } from "react-router-dom"; 2 - import { resolveBBS, type BBS } from "../../lib/bbs"; 3 - import { 4 - getRecordByUri, 5 - listRecords, 6 - resolveIdentitiesBatch, 7 - } from "../../lib/atproto"; 8 - import { BAN, HIDE } from "../../lib/lexicon"; 9 - import { parseAtUri } from "../../lib/util"; 1 + /** Load a sysop's bans + hides, hydrated with identities and post previews. */ 2 + 3 + import { getRecordByUri, listRecords, resolveIdentitiesBatch } from "./atproto"; 4 + import { BAN, HIDE } from "./lexicon"; 5 + import { parseAtUri } from "./util"; 10 6 import { is } from "@atcute/lexicons/validations"; 11 - import { mainSchema as banSchema } from "../../lexicons/types/xyz/atbbs/ban"; 12 - import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atbbs/hide"; 13 - import type { XyzAtbbsBan, XyzAtbbsHide } from "../../lexicons"; 14 - import { requireAuth } from "./auth"; 7 + import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; 8 + import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; 9 + import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons"; 15 10 16 11 export interface HiddenInfo { 17 12 uri: string; ··· 20 15 body: string; 21 16 } 22 17 18 + export interface SysopModeration { 19 + banRkeys: Record<string, string>; 20 + bannedHandles: Record<string, string>; 21 + hideRkeys: Record<string, string>; 22 + hidden: HiddenInfo[]; 23 + } 24 + 23 25 function buildRkeyMap<T>( 24 26 records: { uri: string; value: Record<string, unknown> }[], 25 27 schema: Parameters<typeof is>[0], ··· 47 49 const did = parseAtUri(uri).did; 48 50 const handle = identities[did]?.handle ?? did; 49 51 const result = records[index]; 50 - 51 52 if (result.status === "fulfilled") { 52 53 const value = result.value.value as unknown as { 53 54 title?: string; ··· 60 61 body: (value.body ?? "").substring(0, 100), 61 62 }; 62 63 } 63 - 64 64 return { uri, handle, title: "", body: uri }; 65 65 }); 66 66 } 67 67 68 - export async function sysopEditLoader() { 69 - const user = await requireAuth(); 70 - try { 71 - const bbs = await resolveBBS(user.handle); 72 - return { user, bbs }; 73 - } catch { 74 - throw redirect("/account/create"); 75 - } 76 - } 77 - 78 - export async function sysopModerateLoader() { 79 - const user = await requireAuth(); 80 - 81 - let bbs: BBS; 82 - try { 83 - bbs = await resolveBBS(user.handle); 84 - } catch { 85 - throw redirect("/account/create"); 86 - } 87 - 68 + export async function fetchSysopModeration( 69 + pdsUrl: string, 70 + did: string, 71 + ): Promise<SysopModeration> { 88 72 const [banRecs, hideRecs] = await Promise.all([ 89 - listRecords(user.pdsUrl, user.did, BAN), 90 - listRecords(user.pdsUrl, user.did, HIDE), 73 + listRecords(pdsUrl, did, BAN), 74 + listRecords(pdsUrl, did, HIDE), 91 75 ]); 92 76 93 77 const banRkeys = buildRkeyMap<XyzAtbbsBan.Main>( ··· 113 97 } 114 98 } 115 99 116 - const hiddenUris = Object.keys(hideRkeys); 117 - const hidden = await hydrateHiddenPosts(hiddenUris); 100 + const hidden = await hydrateHiddenPosts(Object.keys(hideRkeys)); 118 101 119 - return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden }; 102 + return { banRkeys, bannedHandles, hideRkeys, hidden }; 120 103 }
-71
web/src/router/loaders/thread.ts
··· 1 - import type { LoaderFunctionArgs } from "react-router-dom"; 2 - import { resolveBBS } from "../../lib/bbs"; 3 - import { 4 - getRecord, 5 - getBacklinks, 6 - resolveIdentity, 7 - type BacklinkRef, 8 - } from "../../lib/atproto"; 9 - import { POST } from "../../lib/lexicon"; 10 - import { makeAtUri, parseAtUri } from "../../lib/util"; 11 - import { is } from "@atcute/lexicons/validations"; 12 - import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; 13 - import type { XyzAtbbsPost } from "../../lexicons"; 14 - 15 - export interface ThreadObj { 16 - uri: string; 17 - did: string; 18 - rkey: string; 19 - authorHandle: string; 20 - authorPds: string; 21 - title: string; 22 - body: string; 23 - createdAt: string; 24 - boardSlug: string; 25 - attachments?: { file: { ref: { $link: string } }; name: string }[]; 26 - } 27 - 28 - async function collectAllReplyRefs(rootUri: string): Promise<BacklinkRef[]> { 29 - const collected: BacklinkRef[] = []; 30 - let cursor: string | undefined; 31 - for (let i = 0; i < 20; i++) { 32 - const page = await getBacklinks(rootUri, `${POST}:root`, 100, cursor); 33 - collected.push(...page.records); 34 - if (!page.cursor) break; 35 - cursor = page.cursor; 36 - } 37 - return collected.reverse(); // oldest first 38 - } 39 - 40 - export async function threadLoader({ params }: LoaderFunctionArgs) { 41 - const handle = params.handle!; 42 - const did = params.did!; 43 - const tid = params.tid!; 44 - 45 - const threadUri = makeAtUri(did, POST, tid); 46 - const [bbs, threadRecord, author, allRefs] = await Promise.all([ 47 - resolveBBS(handle), 48 - getRecord(did, POST, tid), 49 - resolveIdentity(did), 50 - collectAllReplyRefs(threadUri), 51 - ]); 52 - if (!is(postSchema, threadRecord.value)) { 53 - throw new Response("Invalid post record", { status: 404 }); 54 - } 55 - const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; 56 - const boardSlug = parseAtUri(postValue.scope).rkey; 57 - const thread: ThreadObj = { 58 - uri: threadRecord.uri, 59 - did, 60 - rkey: tid, 61 - authorHandle: author.handle, 62 - authorPds: author.pds ?? "", 63 - title: postValue.title ?? "", 64 - body: postValue.body, 65 - createdAt: postValue.createdAt, 66 - boardSlug, 67 - attachments: postValue.attachments as ThreadObj["attachments"], 68 - }; 69 - 70 - return { handle, bbs, thread, allRefs }; 71 - }
+11 -54
web/src/router/routes.tsx
··· 1 - import { 2 - createBrowserRouter, 3 - Outlet, 4 - redirect, 5 - type RouteObject, 6 - } from "react-router-dom"; 1 + import { createBrowserRouter, Outlet, redirect } from "react-router-dom"; 7 2 8 3 import Layout from "../components/layout/Layout"; 9 - import ErrorPage from "../components/layout/ErrorPage"; 10 - import HydrateFallback from "../components/layout/HydrateFallback"; 11 4 12 5 import Home from "../pages/Home"; 13 6 import OAuthCallback from "../pages/OAuthCallback"; ··· 21 14 import News from "../pages/News"; 22 15 import NotFound from "../pages/NotFound"; 23 16 24 - import { 25 - homeLoader, 26 - bbsLoader, 27 - boardLoader, 28 - profileLoader, 29 - threadLoader, 30 - requireAuthLoader, 31 - sysopEditLoader, 32 - sysopModerateLoader, 33 - } from "./loaders"; 17 + import { requireAuthLoader, requireSysopBBSLoader } from "./loaders"; 34 18 35 - const routes: RouteObject[] = [ 19 + export const router = createBrowserRouter([ 36 20 { 37 21 element: <Layout />, 38 - errorElement: <ErrorPage />, 39 - HydrateFallback, 40 22 children: [ 41 - { path: "/", loader: homeLoader, element: <Home /> }, 23 + { path: "/", element: <Home /> }, 42 24 { path: "/oauth/callback", element: <OAuthCallback /> }, 43 25 { path: "/account", loader: () => redirect("/") }, 44 26 { 45 27 path: "/account/create", 46 28 loader: requireAuthLoader, 47 29 element: <SysopCreate />, 48 - errorElement: <ErrorPage />, 49 30 }, 50 31 { 51 32 path: "/account/edit", 52 - loader: sysopEditLoader, 33 + loader: requireSysopBBSLoader, 53 34 element: <SysopEdit />, 54 - errorElement: <ErrorPage />, 55 35 }, 56 36 { 57 37 path: "/account/moderate", 58 - loader: sysopModerateLoader, 38 + loader: requireSysopBBSLoader, 59 39 element: <SysopModerate />, 60 40 }, 61 41 { 62 42 path: "/bbs/:handle", 63 - id: "bbs", 64 - loader: bbsLoader, 65 43 element: <Outlet />, 66 - errorElement: <ErrorPage />, 67 44 children: [ 68 45 { index: true, element: <BBS /> }, 69 - { 70 - path: "board/:slug", 71 - loader: boardLoader, 72 - element: <Board />, 73 - errorElement: <ErrorPage />, 74 - }, 75 - { 76 - path: "thread/:did/:tid", 77 - loader: threadLoader, 78 - element: <Thread />, 79 - errorElement: <ErrorPage />, 80 - }, 81 - { 82 - path: "news/:tid", 83 - element: <News />, 84 - }, 46 + { path: "board/:slug", element: <Board /> }, 47 + { path: "thread/:did/:tid", element: <Thread /> }, 48 + { path: "news/:tid", element: <News /> }, 85 49 ], 86 50 }, 87 - { 88 - path: "/profile/:handle", 89 - loader: profileLoader, 90 - element: <Profile />, 91 - errorElement: <ErrorPage />, 92 - }, 51 + { path: "/profile/:handle", element: <Profile /> }, 93 52 { path: "*", element: <NotFound /> }, 94 53 ], 95 54 }, 96 - ]; 97 - 98 - export const router = createBrowserRouter(routes); 55 + ]);