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: cleanup components

+1059 -1037
+52
web/src/components/BBSPanel.tsx
··· 1 + import { Link } from "react-router-dom"; 2 + 3 + const cardStyle = 4 + "bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-300 hover:text-neutral-200 hover:border-neutral-700"; 5 + 6 + interface BBSPanelProps { 7 + hasBBS: boolean; 8 + userHandle: string; 9 + onDelete: () => void; 10 + } 11 + 12 + export default function BBSPanel({ hasBBS, userHandle, onDelete }: BBSPanelProps) { 13 + if (!hasBBS) { 14 + return ( 15 + <> 16 + <p className="text-neutral-500 mb-4"> 17 + You haven't set up a BBS yet. 18 + </p> 19 + <Link 20 + to="/account/create" 21 + className="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded inline-block" 22 + > 23 + create a bbs 24 + </Link> 25 + </> 26 + ); 27 + } 28 + 29 + return ( 30 + <div className="grid grid-cols-2 gap-3 max-w-md"> 31 + <Link to={`/bbs/${userHandle}`} className={cardStyle}> 32 + <div className="text-neutral-200 mb-1">Browse</div> 33 + <div className="text-xs text-neutral-500">View your BBS.</div> 34 + </Link> 35 + <Link to="/account/edit" className={cardStyle}> 36 + <div className="text-neutral-200 mb-1">Edit</div> 37 + <div className="text-xs text-neutral-500">Name, boards, intro.</div> 38 + </Link> 39 + <Link to="/account/moderate" className={cardStyle}> 40 + <div className="text-neutral-200 mb-1">Moderate</div> 41 + <div className="text-xs text-neutral-500">Bans and hidden posts.</div> 42 + </Link> 43 + <button 44 + onClick={onDelete} 45 + className="text-left bg-neutral-900 border border-neutral-800 rounded px-4 py-3 hover:border-red-900" 46 + > 47 + <div className="text-neutral-500 mb-1">Delete</div> 48 + <div className="text-xs text-neutral-600">Remove your BBS.</div> 49 + </button> 50 + </div> 51 + ); 52 + }
+7 -7
web/src/components/BoardRowEditor.tsx
··· 16 16 boards, 17 17 onChange, 18 18 }: BoardRowEditorProps) { 19 - function updateField(index: number, field: keyof BoardRow, value: string) { 20 - const next = boards.map((b, i) => 21 - i === index ? { ...b, [field]: value } : b, 19 + function updateBoard(index: number, field: keyof BoardRow, value: string) { 20 + const updated = boards.map((board, i) => 21 + i === index ? { ...board, [field]: value } : board, 22 22 ); 23 - onChange(next); 23 + onChange(updated); 24 24 } 25 25 26 26 return ( ··· 34 34 <div key={i} className="flex gap-2"> 35 35 <Input 36 36 value={board.slug} 37 - onChange={(e) => updateField(i, "slug", e.target.value)} 37 + onChange={(e) => updateBoard(i, "slug", e.target.value)} 38 38 placeholder="slug" 39 39 className="w-1/4!" 40 40 /> 41 41 <Input 42 42 value={board.name} 43 - onChange={(e) => updateField(i, "name", e.target.value)} 43 + onChange={(e) => updateBoard(i, "name", e.target.value)} 44 44 placeholder="Name" 45 45 maxLength={limits.BOARD_NAME} 46 46 className="w-1/3!" 47 47 /> 48 48 <Input 49 49 value={board.desc} 50 - onChange={(e) => updateField(i, "desc", e.target.value)} 50 + onChange={(e) => updateBoard(i, "desc", e.target.value)} 51 51 placeholder="Description" 52 52 maxLength={limits.BOARD_DESCRIPTION} 53 53 className="flex-1!"
+6 -18
web/src/components/ComposeForm.tsx
··· 1 1 import type { SyntheticEvent } from "react"; 2 2 import { Input, Textarea, Button } from "./Form"; 3 + import FileChips from "./FileChips"; 3 4 import { MAX_ATTACHMENTS } from "../lib/limits"; 4 5 5 6 interface ComposeFormProps { ··· 35 36 onFilesChange, 36 37 quote, 37 38 onClearQuote, 39 + bodyMaxLength, 40 + titleMaxLength, 38 41 submitLabel = "post", 39 42 posting = false, 40 43 className = "", 41 - bodyMaxLength, 42 - titleMaxLength, 43 44 }: ComposeFormProps) { 44 45 function addFiles(fileList: FileList | null) { 45 46 if (!fileList) return; ··· 47 48 onFilesChange(combined); 48 49 } 49 50 50 - const atLimit = files.length >= MAX_ATTACHMENTS; 51 + const attachmentsAtLimit = files.length >= MAX_ATTACHMENTS; 51 52 52 53 function removeFile(index: number) { 53 54 onFilesChange(files.filter((_, i) => i !== index)); ··· 96 97 /> 97 98 98 99 {files.length > 0 && ( 99 - <div className="flex flex-wrap gap-2 text-xs text-neutral-500"> 100 - {files.map((f, i) => ( 101 - <span key={i} className="flex items-center gap-1 bg-neutral-800 px-2 py-1 rounded"> 102 - {f.name} 103 - <button 104 - type="button" 105 - onClick={() => removeFile(i)} 106 - className="text-neutral-500 hover:text-red-400" 107 - > 108 - 109 - </button> 110 - </span> 111 - ))} 112 - </div> 100 + <FileChips files={files} onRemove={removeFile} /> 113 101 )} 114 102 115 103 <div className="flex items-center gap-3"> 116 104 <Button type="submit" disabled={posting}> 117 105 {posting ? "posting..." : submitLabel} 118 106 </Button> 119 - {!atLimit && ( 107 + {!attachmentsAtLimit && ( 120 108 <label className="text-neutral-200 cursor-pointer bg-neutral-800 hover:bg-neutral-700 px-4 py-2 rounded inline-block"> 121 109 attach 122 110 <input
+26
web/src/components/FileChips.tsx
··· 1 + interface FileChipsProps { 2 + files: File[]; 3 + onRemove: (index: number) => void; 4 + } 5 + 6 + export default function FileChips({ files, onRemove }: FileChipsProps) { 7 + return ( 8 + <div className="flex flex-wrap gap-2 text-xs text-neutral-500"> 9 + {files.map((file, i) => ( 10 + <span 11 + key={i} 12 + className="flex items-center gap-1 bg-neutral-800 px-2 py-1 rounded" 13 + > 14 + {file.name} 15 + <button 16 + type="button" 17 + onClick={() => onRemove(i)} 18 + className="text-neutral-500 hover:text-red-400" 19 + > 20 + 21 + </button> 22 + </span> 23 + ))} 24 + </div> 25 + ); 26 + }
+23
web/src/components/Footer.tsx
··· 1 + const linkStyle = "text-neutral-500 hover:text-neutral-300"; 2 + 3 + const links = [ 4 + { href: "https://github.com/alyraffauf/atbbs", label: "github" }, 5 + { href: "https://ko-fi.com/alyraffauf", label: "ko-fi" }, 6 + ]; 7 + 8 + export default function Footer() { 9 + return ( 10 + <footer className="border-t border-neutral-800 mt-auto"> 11 + <div className="max-w-2xl mx-auto px-4 py-4 flex items-center justify-between text-xs text-neutral-500"> 12 + <span> 13 + made by <a href="https://aly.codes" className={linkStyle}>aly.codes</a> 14 + </span> 15 + <div className="flex items-center gap-4"> 16 + {links.map(({ href, label }) => ( 17 + <a key={label} href={href} className={linkStyle}>{label}</a> 18 + ))} 19 + </div> 20 + </div> 21 + </footer> 22 + ); 23 + }
+5 -9
web/src/components/HandleInput.tsx
··· 12 12 "handle.your-domain.com", 13 13 ]; 14 14 15 - // Props for HandleInput. Extends standard <input> props so callers can 16 - // pass things like `required`, `disabled`, `id`, etc. 17 15 interface HandleInputProps extends Omit< 18 16 InputHTMLAttributes<HTMLInputElement>, 19 17 "onChange" | "value" 20 18 > { 21 19 value: string; 22 - onChange: (v: string) => void; 20 + onChange: (value: string) => void; 23 21 } 24 22 25 23 export default function HandleInput({ 26 24 value, 27 25 onChange, 28 26 className = "", 29 - ...rest // any extra <input> attributes (required, disabled, etc.) 27 + ...rest 30 28 }: HandleInputProps) { 31 - // Cycle through placeholder examples every 3 seconds. 32 - const [index, setIndex] = useState(0); 29 + const [placeholderIndex, setPlaceholderIndex] = useState(0); 33 30 34 31 useEffect(() => { 35 32 const timer = setInterval(() => { 36 - setIndex((i) => (i + 1) % PLACEHOLDERS.length); 33 + setPlaceholderIndex((i) => (i + 1) % PLACEHOLDERS.length); 37 34 }, 3000); 38 35 39 - // Stop the timer when this component is removed from the page. 40 36 return () => clearInterval(timer); 41 37 }, []); 42 38 ··· 45 41 type="text" 46 42 value={value} 47 43 onChange={(e) => onChange(e.target.value)} 48 - placeholder={PLACEHOLDERS[index]} 44 + placeholder={PLACEHOLDERS[placeholderIndex]} 49 45 className={`${inputStyles} ${className}`} 50 46 {...rest} 51 47 />
+42
web/src/components/Header.tsx
··· 1 + import { Link, useNavigate } from "react-router-dom"; 2 + import { useAuth } from "../lib/auth"; 3 + import Logo from "./Logo"; 4 + import HeaderBreadcrumbs from "./HeaderBreadcrumbs"; 5 + import MobileMenu from "./MobileMenu"; 6 + 7 + const linkStyle = "text-neutral-500 hover:text-neutral-300"; 8 + 9 + export default function Header() { 10 + const { user, logout } = useAuth(); 11 + const navigate = useNavigate(); 12 + 13 + async function onLogout() { 14 + await logout(); 15 + navigate("/"); 16 + } 17 + 18 + return ( 19 + <header className="border-b border-neutral-800"> 20 + <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> 21 + <div className="hidden md:flex items-center gap-2 text-neutral-500 min-w-0 whitespace-nowrap"> 22 + <Logo /> 23 + <HeaderBreadcrumbs /> 24 + </div> 25 + <div className="md:hidden"> 26 + <Logo /> 27 + </div> 28 + <div className="hidden md:flex items-center gap-3 shrink-0 ml-4"> 29 + {user ? ( 30 + <> 31 + <Link to="/account" className={linkStyle}>{user.handle}</Link> 32 + <button type="button" onClick={onLogout} className={linkStyle}>log out</button> 33 + </> 34 + ) : ( 35 + <Link to="/login" className={linkStyle}>log in</Link> 36 + )} 37 + </div> 38 + <MobileMenu user={user} onLogout={onLogout} /> 39 + </div> 40 + </header> 41 + ); 42 + }
+34
web/src/components/HeaderBreadcrumbs.tsx
··· 1 + import { Link } from "react-router-dom"; 2 + import { useBreadcrumbState } from "../hooks/useBreadcrumb"; 3 + 4 + export default function HeaderBreadcrumbs() { 5 + const { crumbs } = useBreadcrumbState(); 6 + if (!crumbs.length) return null; 7 + 8 + return ( 9 + <> 10 + {crumbs.flatMap((crumb, index) => { 11 + const isLast = index === crumbs.length - 1; 12 + const separator = <span key={`sep-${index}`}>/</span>; 13 + const element = 14 + crumb.to && !isLast ? ( 15 + <Link 16 + key={`crumb-${index}`} 17 + to={crumb.to} 18 + className="text-neutral-500 hover:text-neutral-300" 19 + > 20 + {crumb.label} 21 + </Link> 22 + ) : ( 23 + <span 24 + key={`crumb-${index}`} 25 + className="text-neutral-400 truncate" 26 + > 27 + {crumb.label} 28 + </span> 29 + ); 30 + return [separator, element]; 31 + })} 32 + </> 33 + ); 34 + }
+57
web/src/components/InboxList.tsx
··· 1 + import { useState } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { parseAtUri } from "../lib/util"; 4 + import PostBody from "./PostBody"; 5 + import PostMeta from "./PostMeta"; 6 + import type { InboxItem } from "../router/loaders"; 7 + 8 + const PAGE_SIZE = 10; 9 + 10 + interface InboxListProps { 11 + items: InboxItem[]; 12 + userHandle: string; 13 + } 14 + 15 + export default function InboxList({ items, userHandle }: InboxListProps) { 16 + const [shown, setShown] = useState(PAGE_SIZE); 17 + 18 + if (items.length === 0) 19 + return <p className="text-neutral-500">No messages yet.</p>; 20 + 21 + return ( 22 + <div> 23 + {items.slice(0, shown).map((item) => { 24 + const { did: threadDid, rkey: threadRkey } = parseAtUri(item.threadUri); 25 + const { rkey: replyRkey } = parseAtUri(item.replyUri); 26 + const url = `/bbs/${userHandle}/thread/${threadDid}/${threadRkey}#reply-${replyRkey}`; 27 + return ( 28 + <Link 29 + key={item.replyUri} 30 + to={url} 31 + className="block border border-neutral-800/50 rounded p-4 mb-2 hover:bg-neutral-800" 32 + > 33 + <PostMeta handle={item.handle} createdAt={item.createdAt} /> 34 + <p className="text-xs text-neutral-500 mb-1"> 35 + {item.type === "quote" 36 + ? "quoted your reply" 37 + : `on: ${item.threadTitle}`} 38 + </p> 39 + <div className="line-clamp-2"> 40 + <PostBody>{item.body}</PostBody> 41 + </div> 42 + </Link> 43 + ); 44 + })} 45 + {shown < items.length && ( 46 + <div className="mt-4 text-center"> 47 + <button 48 + onClick={() => setShown((prev) => prev + PAGE_SIZE)} 49 + className="text-neutral-500 hover:text-neutral-300" 50 + > 51 + show more 52 + </button> 53 + </div> 54 + )} 55 + </div> 56 + ); 57 + }
+8 -207
web/src/components/Layout.tsx
··· 1 - import { 2 - Link, 3 - Outlet, 4 - useNavigate, 5 - useNavigation, 6 - } from "react-router-dom"; 7 - import type { ReactNode } from "react"; 8 - import { useState } from "react"; 9 - import { useAuth } from "../lib/auth"; 10 - import { useBreadcrumbState, type Crumb } from "../hooks/useBreadcrumb"; 1 + import { Outlet, useNavigation } from "react-router-dom"; 2 + import Header from "./Header"; 3 + import MobileBackButton from "./MobileBackButton"; 4 + import Footer from "./Footer"; 11 5 12 6 export default function Layout() { 13 - const { user, logout } = useAuth(); 14 - const navigation = useNavigation(); 15 - const navigate = useNavigate(); 16 - const isLoading = navigation.state === "loading"; 17 - 18 - async function onLogout() { 19 - await logout(); 20 - navigate("/"); 21 - } 7 + const isLoading = useNavigation().state === "loading"; 22 8 23 9 return ( 24 10 <div className="flex flex-col h-dvh"> ··· 28 14 style={{ animation: "atbbs-progress 1.5s ease-out infinite" }} 29 15 /> 30 16 )} 31 - <header className="border-b border-neutral-800"> 32 - <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> 33 - {/* Desktop: logo + breadcrumbs inline */} 34 - <div className="hidden md:flex items-center gap-2 text-neutral-500 min-w-0 whitespace-nowrap"> 35 - <Logo /> 36 - <HeaderBreadcrumbs /> 37 - </div> 38 - {/* Mobile/tablet: logo only */} 39 - <div className="md:hidden"> 40 - <Logo /> 41 - </div> 42 - {/* Desktop: inline links */} 43 - <div className="hidden md:flex items-center gap-3 shrink-0 ml-4"> 44 - {user ? ( 45 - <> 46 - <Link 47 - to="/account" 48 - className="text-neutral-500 hover:text-neutral-300" 49 - > 50 - {user.handle} 51 - </Link> 52 - <button 53 - type="button" 54 - onClick={onLogout} 55 - className="text-neutral-500 hover:text-neutral-300" 56 - > 57 - log out 58 - </button> 59 - </> 60 - ) : ( 61 - <Link 62 - to="/login" 63 - className="text-neutral-500 hover:text-neutral-300" 64 - > 65 - log in 66 - </Link> 67 - )} 68 - </div> 69 - {/* Mobile: hamburger menu */} 70 - <MobileMenu user={user} onLogout={onLogout} /> 71 - </div> 72 - </header> 17 + <Header /> 73 18 <main className="max-w-2xl mx-auto px-4 py-8 flex-1 w-full"> 74 - <Navigation /> 19 + <MobileBackButton /> 75 20 <Outlet /> 76 21 </main> 77 - <footer className="border-t border-neutral-800 mt-auto"> 78 - <div className="max-w-2xl mx-auto px-4 py-4 flex items-center justify-between text-xs text-neutral-500"> 79 - <span> 80 - made by{" "} 81 - <a 82 - href="https://aly.codes" 83 - className="text-neutral-500 hover:text-neutral-300" 84 - > 85 - aly.codes 86 - </a> 87 - </span> 88 - <div className="flex items-center gap-4"> 89 - <a 90 - href="https://github.com/alyraffauf/atbbs" 91 - className="text-neutral-500 hover:text-neutral-300" 92 - > 93 - github 94 - </a> 95 - <a 96 - href="https://ko-fi.com/alyraffauf" 97 - className="text-neutral-500 hover:text-neutral-300" 98 - > 99 - ko-fi 100 - </a> 101 - </div> 102 - </div> 103 - </footer> 22 + <Footer /> 104 23 </div> 105 24 ); 106 25 } 107 - 108 - function MobileMenu({ 109 - user, 110 - onLogout, 111 - }: { 112 - user: ReturnType<typeof useAuth>["user"]; 113 - onLogout: () => void; 114 - }) { 115 - const [open, setOpen] = useState(false); 116 - 117 - return ( 118 - <div className="md:hidden relative"> 119 - <button 120 - type="button" 121 - onClick={() => setOpen(!open)} 122 - className="text-neutral-400 hover:text-neutral-300 text-lg px-1" 123 - aria-label="Menu" 124 - > 125 - {open ? "✕" : "☰"} 126 - </button> 127 - {open && ( 128 - <div className="z-50 fixed inset-0 top-[49px] bg-neutral-950/95 flex flex-col items-center pt-12 gap-6 text-lg sm:absolute sm:inset-auto sm:right-0 sm:top-full sm:mt-2 sm:bg-neutral-900 sm:border sm:border-neutral-800 sm:rounded sm:py-2 sm:px-4 sm:gap-2 sm:text-sm sm:pt-0 sm:min-w-40"> 129 - {user ? ( 130 - <> 131 - <Link 132 - to="/account" 133 - onClick={() => setOpen(false)} 134 - className="text-neutral-300 hover:text-neutral-200" 135 - > 136 - {user.handle} 137 - </Link> 138 - <button 139 - type="button" 140 - onClick={() => { 141 - setOpen(false); 142 - onLogout(); 143 - }} 144 - className="text-neutral-500 hover:text-neutral-300" 145 - > 146 - log out 147 - </button> 148 - </> 149 - ) : ( 150 - <Link 151 - to="/login" 152 - onClick={() => setOpen(false)} 153 - className="text-neutral-300 hover:text-neutral-200" 154 - > 155 - log in 156 - </Link> 157 - )} 158 - </div> 159 - )} 160 - </div> 161 - ); 162 - } 163 - 164 - function Logo() { 165 - return ( 166 - <Link to="/" className="shrink-0 hover:opacity-80"> 167 - <picture> 168 - <source srcSet="/hero-dark.svg" media="(prefers-color-scheme: dark)" /> 169 - <img 170 - src="/hero.svg" 171 - alt="@bbs" 172 - style={{ height: "1.25rem", imageRendering: "pixelated" }} 173 - className="inline-block" 174 - /> 175 - </picture> 176 - </Link> 177 - ); 178 - } 179 - 180 - function Navigation() { 181 - const { crumbs } = useBreadcrumbState(); 182 - if (crumbs.length <= 1) return null; 183 - const parent = crumbs[crumbs.length - 2]; 184 - if (!parent?.to) return null; 185 - 186 - return ( 187 - <Link 188 - to={parent.to} 189 - className="md:hidden inline-block mb-6 px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs" 190 - > 191 - ← {parent.label} 192 - </Link> 193 - ); 194 - } 195 - 196 - function HeaderBreadcrumbs() { 197 - const { crumbs } = useBreadcrumbState(); 198 - if (!crumbs.length) return null; 199 - 200 - const out: ReactNode[] = []; 201 - crumbs.forEach((c: Crumb, i: number) => { 202 - out.push(<span key={`s${i}`}>/</span>); 203 - const last = i === crumbs.length - 1; 204 - if (c.to && !last) { 205 - out.push( 206 - <Link 207 - key={`c${i}`} 208 - to={c.to} 209 - className="text-neutral-500 hover:text-neutral-300" 210 - > 211 - {c.label} 212 - </Link>, 213 - ); 214 - } else { 215 - out.push( 216 - <span key={`c${i}`} className="text-neutral-400 truncate"> 217 - {c.label} 218 - </span>, 219 - ); 220 - } 221 - }); 222 - 223 - return <>{out}</>; 224 - }
+17
web/src/components/Logo.tsx
··· 1 + import { Link } from "react-router-dom"; 2 + 3 + export default function Logo() { 4 + return ( 5 + <Link to="/" className="shrink-0 hover:opacity-80"> 6 + <picture> 7 + <source srcSet="/hero-dark.svg" media="(prefers-color-scheme: dark)" /> 8 + <img 9 + src="/hero.svg" 10 + alt="@bbs" 11 + style={{ height: "1.25rem", imageRendering: "pixelated" }} 12 + className="inline-block" 13 + /> 14 + </picture> 15 + </Link> 16 + ); 17 + }
+18
web/src/components/MobileBackButton.tsx
··· 1 + import { Link } from "react-router-dom"; 2 + import { useBreadcrumbState } from "../hooks/useBreadcrumb"; 3 + 4 + export default function MobileBackButton() { 5 + const { crumbs } = useBreadcrumbState(); 6 + if (crumbs.length <= 1) return null; 7 + const parent = crumbs[crumbs.length - 2]; 8 + if (!parent?.to) return null; 9 + 10 + return ( 11 + <Link 12 + to={parent.to} 13 + className="md:hidden inline-block mb-6 px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs" 14 + > 15 + ← {parent.label} 16 + </Link> 17 + ); 18 + }
+56
web/src/components/MobileMenu.tsx
··· 1 + import { Link } from "react-router-dom"; 2 + import { useState } from "react"; 3 + import type { useAuth } from "../lib/auth"; 4 + 5 + interface MobileMenuProps { 6 + user: ReturnType<typeof useAuth>["user"]; 7 + onLogout: () => void; 8 + } 9 + 10 + // Fullscreen on phone, dropdown on tablet 11 + const panelStyle = [ 12 + "z-50 fixed inset-0 top-12.25 bg-neutral-950/95", 13 + "flex flex-col items-center pt-12 gap-6 text-lg", 14 + "sm:absolute sm:inset-auto sm:right-0 sm:top-full sm:mt-2", 15 + "sm:bg-neutral-900 sm:border sm:border-neutral-800 sm:rounded", 16 + "sm:py-2 sm:px-4 sm:gap-2 sm:text-sm sm:pt-0 sm:min-w-40", 17 + ].join(" "); 18 + 19 + export default function MobileMenu({ user, onLogout }: MobileMenuProps) { 20 + const [open, setOpen] = useState(false); 21 + 22 + function close() { 23 + setOpen(false); 24 + } 25 + 26 + return ( 27 + <div className="md:hidden relative"> 28 + <button 29 + type="button" 30 + onClick={() => setOpen(!open)} 31 + className="text-neutral-400 hover:text-neutral-300 text-lg px-1" 32 + aria-label="Menu" 33 + > 34 + {open ? "✕" : "☰"} 35 + </button> 36 + {open && ( 37 + <div className={panelStyle}> 38 + {user ? ( 39 + <> 40 + <Link to="/account" onClick={close} className="text-neutral-300 hover:text-neutral-200"> 41 + {user.handle} 42 + </Link> 43 + <button type="button" onClick={() => { close(); onLogout(); }} className="text-neutral-500 hover:text-neutral-300"> 44 + log out 45 + </button> 46 + </> 47 + ) : ( 48 + <Link to="/login" onClick={close} className="text-neutral-300 hover:text-neutral-200"> 49 + log in 50 + </Link> 51 + )} 52 + </div> 53 + )} 54 + </div> 55 + ); 56 + }
+38
web/src/components/PostActions.tsx
··· 1 + const actionStyle = "text-xs text-neutral-500 hover:text-red-400"; 2 + 3 + interface PostActionsProps { 4 + isAuthor: boolean; 5 + isSysop: boolean; 6 + onDelete?: () => void; 7 + onBan?: () => void; 8 + onHide?: () => void; 9 + onQuote?: () => void; 10 + } 11 + 12 + export default function PostActions({ 13 + isAuthor, 14 + isSysop, 15 + onDelete, 16 + onBan, 17 + onHide, 18 + onQuote, 19 + }: PostActionsProps) { 20 + return ( 21 + <span className="reply-actions flex items-center gap-3"> 22 + {onQuote && ( 23 + <button onClick={onQuote} className="text-xs text-neutral-500 hover:text-neutral-300"> 24 + quote 25 + </button> 26 + )} 27 + {isAuthor && onDelete && ( 28 + <button onClick={onDelete} className={actionStyle}>delete</button> 29 + )} 30 + {isSysop && !isAuthor && onBan && ( 31 + <button onClick={onBan} className={actionStyle}>ban</button> 32 + )} 33 + {isSysop && onHide && ( 34 + <button onClick={onHide} className={actionStyle}>hide</button> 35 + )} 36 + </span> 37 + ); 38 + }
+21
web/src/components/PostMeta.tsx
··· 1 + import { formatFullDate, relativeDate } from "../lib/util"; 2 + 3 + interface PostMetaProps { 4 + handle: string; 5 + createdAt: string; 6 + } 7 + 8 + export default function PostMeta({ handle, createdAt }: PostMetaProps) { 9 + return ( 10 + <div className="flex items-baseline gap-2"> 11 + <span className="text-neutral-200">{handle}</span> 12 + <span className="text-neutral-600">·</span> 13 + <time 14 + className="text-xs text-neutral-500" 15 + title={formatFullDate(createdAt)} 16 + > 17 + {relativeDate(createdAt)} 18 + </time> 19 + </div> 20 + ); 21 + }
+14 -48
web/src/components/ReplyCard.tsx
··· 1 - import { formatFullDate, parseAtUri, relativeDate } from "../lib/util"; 2 1 import AttachmentLink from "./AttachmentLink"; 3 - import PostBody from "./PostBody.tsx"; 2 + import PostActions from "./PostActions"; 3 + import PostBody from "./PostBody"; 4 + import PostMeta from "./PostMeta"; 4 5 5 6 export interface Reply { 6 7 uri: string; ··· 46 47 className="reply-card border border-neutral-800/50 rounded p-4" 47 48 > 48 49 <div className="flex items-baseline justify-between mb-2"> 49 - <div className="flex items-baseline gap-2"> 50 - <span className="text-neutral-300">{reply.handle}</span> 51 - <span className="text-neutral-600">·</span> 52 - <time 53 - className="text-xs text-neutral-500" 54 - title={formatFullDate(reply.createdAt)} 55 - > 56 - {relativeDate(reply.createdAt)} 57 - </time> 58 - </div> 59 - <span className="reply-actions flex items-center gap-3"> 60 - {userDid && ( 61 - <button 62 - onClick={onQuote} 63 - className="text-xs text-neutral-500 hover:text-neutral-300" 64 - > 65 - quote 66 - </button> 67 - )} 68 - {isAuthor && ( 69 - <button 70 - onClick={onDelete} 71 - className="text-xs text-neutral-500 hover:text-red-400" 72 - > 73 - delete 74 - </button> 75 - )} 76 - {isSysop && !isAuthor && ( 77 - <button 78 - onClick={onBan} 79 - className="text-xs text-neutral-500 hover:text-red-400" 80 - > 81 - ban 82 - </button> 83 - )} 84 - {isSysop && ( 85 - <button 86 - onClick={onHide} 87 - className="text-xs text-neutral-500 hover:text-red-400" 88 - > 89 - hide 90 - </button> 91 - )} 92 - </span> 50 + <PostMeta handle={reply.handle} createdAt={reply.createdAt} /> 51 + <PostActions 52 + isAuthor={isAuthor} 53 + isSysop={isSysop} 54 + onQuote={userDid ? onQuote : undefined} 55 + onDelete={onDelete} 56 + onBan={onBan} 57 + onHide={onHide} 58 + /> 93 59 </div> 94 60 95 61 {quoted && ( ··· 108 74 109 75 <PostBody>{reply.body}</PostBody> 110 76 111 - {reply.attachments.map((attachment, i) => ( 77 + {reply.attachments.map((attachment, index) => ( 112 78 <AttachmentLink 113 - key={i} 79 + key={index} 114 80 pds={reply.pds} 115 81 did={reply.did} 116 82 cid={attachment.file.ref.$link}
+58
web/src/components/ThreadCard.tsx
··· 1 + import type { ThreadObj } from "../router/loaders"; 2 + import AttachmentLink from "./AttachmentLink"; 3 + import PostActions from "./PostActions"; 4 + import PostBody from "./PostBody"; 5 + import PostMeta from "./PostMeta"; 6 + 7 + interface ThreadHeaderProps { 8 + thread: ThreadObj; 9 + userDid?: string; 10 + sysopDid: string; 11 + onDelete: () => void; 12 + onBan: () => void; 13 + onHide: () => void; 14 + } 15 + 16 + export default function ThreadCard({ 17 + thread, 18 + userDid, 19 + sysopDid, 20 + onDelete, 21 + onBan, 22 + onHide, 23 + }: ThreadHeaderProps) { 24 + const isAuthor = !!(userDid && userDid === thread.did); 25 + const isSysop = !!(userDid && userDid === sysopDid); 26 + 27 + return ( 28 + <article className="reply-card bg-neutral-900 border border-neutral-800 rounded p-4 mb-4"> 29 + <div className="flex items-baseline justify-between mb-3"> 30 + <PostMeta handle={thread.authorHandle} createdAt={thread.createdAt} /> 31 + <PostActions 32 + isAuthor={isAuthor} 33 + isSysop={isSysop} 34 + onDelete={onDelete} 35 + onBan={onBan} 36 + onHide={onHide} 37 + /> 38 + </div> 39 + <h1 className="text-lg text-neutral-200 font-bold mb-3"> 40 + {thread.title} 41 + </h1> 42 + <PostBody>{thread.body}</PostBody> 43 + {thread.attachments && thread.attachments.length > 0 && ( 44 + <div className="mt-3 space-y-1"> 45 + {thread.attachments.map((attachment, index) => ( 46 + <AttachmentLink 47 + key={index} 48 + pds={thread.authorPds} 49 + did={thread.did} 50 + cid={attachment.file.ref.$link} 51 + name={attachment.name} 52 + /> 53 + ))} 54 + </div> 55 + )} 56 + </article> 57 + ); 58 + }
-84
web/src/components/ThreadHeader.tsx
··· 1 - import { formatFullDate, relativeDate } from "../lib/util"; 2 - import type { ThreadObj } from "../router/loaders"; 3 - import AttachmentLink from "./AttachmentLink"; 4 - import PostBody from "./PostBody"; 5 - 6 - interface ThreadHeaderProps { 7 - thread: ThreadObj; 8 - userDid?: string; 9 - sysopDid: string; 10 - onDeleteThread: () => void; 11 - onBanAuthor: () => void; 12 - onHideThread: () => void; 13 - } 14 - 15 - export default function ThreadHeader({ 16 - thread, 17 - userDid, 18 - sysopDid, 19 - onDeleteThread, 20 - onBanAuthor, 21 - onHideThread, 22 - }: ThreadHeaderProps) { 23 - const isAuthor = userDid && userDid === thread.did; 24 - const isSysop = userDid && userDid === sysopDid; 25 - return ( 26 - <article className="reply-card bg-neutral-900 border border-neutral-800 rounded p-4 mb-4"> 27 - <div className="flex items-baseline justify-between mb-3"> 28 - <div className="flex items-baseline gap-2"> 29 - <span className="text-neutral-200">{thread.authorHandle}</span> 30 - <span className="text-neutral-600">·</span> 31 - <time 32 - className="text-xs text-neutral-500" 33 - title={formatFullDate(thread.createdAt)} 34 - > 35 - {relativeDate(thread.createdAt)} 36 - </time> 37 - </div> 38 - <span className="reply-actions flex items-center gap-3"> 39 - {isAuthor && ( 40 - <button 41 - onClick={onDeleteThread} 42 - className="text-xs text-neutral-500 hover:text-red-400" 43 - > 44 - delete 45 - </button> 46 - )} 47 - {isSysop && !isAuthor && ( 48 - <button 49 - onClick={onBanAuthor} 50 - className="text-xs text-neutral-500 hover:text-red-400" 51 - > 52 - ban 53 - </button> 54 - )} 55 - {isSysop && ( 56 - <button 57 - onClick={onHideThread} 58 - className="text-xs text-neutral-500 hover:text-red-400" 59 - > 60 - hide 61 - </button> 62 - )} 63 - </span> 64 - </div> 65 - <h1 className="text-lg text-neutral-200 font-bold mb-3"> 66 - {thread.title} 67 - </h1> 68 - <PostBody>{thread.body}</PostBody> 69 - {thread.attachments && thread.attachments.length > 0 && ( 70 - <div className="mt-3 space-y-1"> 71 - {thread.attachments.map((a, i) => ( 72 - <AttachmentLink 73 - key={i} 74 - pds={thread.authorPds} 75 - did={thread.did} 76 - cid={a.file.ref.$link} 77 - name={a.name} 78 - /> 79 - ))} 80 - </div> 81 - )} 82 - </article> 83 - ); 84 - }
+18 -83
web/src/hooks/useThreadReplies.ts
··· 6 6 import { getRecordsBatch, resolveIdentitiesBatch } from "../lib/atproto"; 7 7 import { parseAtUri } from "../lib/util"; 8 8 import type { BBS } from "../lib/bbs"; 9 - import { is } from "@atcute/lexicons/validations"; 10 - import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; 11 - import type { XyzAtboardsReply } from "../lexicons"; 12 9 import type { Reply } from "../components/ReplyCard"; 13 - 14 - const REPLIES_PER_PAGE = 10; 15 - 16 - interface BacklinkRef { 17 - did: string; 18 - collection: string; 19 - rkey: string; 20 - } 10 + import { 11 + REPLIES_PER_PAGE, 12 + type BacklinkRef, 13 + refToUri, 14 + pageForReply, 15 + rkeyFromHash, 16 + pageForRkey, 17 + clampPage, 18 + recordToReply, 19 + } from "../lib/replies"; 21 20 22 21 interface ThreadLoaderData { 23 22 bbs: BBS; 24 23 allRefs: BacklinkRef[]; 25 24 } 26 25 27 - function refToUri(ref: BacklinkRef): string { 28 - return `at://${ref.did}/${ref.collection}/${ref.rkey}`; 29 - } 30 - 31 - function pageForReply( 32 - refs: BacklinkRef[], 33 - replyUri: string | null, 34 - ): number | null { 35 - if (!replyUri) return null; 36 - const index = refs.findIndex((r) => refToUri(r) === replyUri); 37 - return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null; 38 - } 39 - 40 - function rkeyFromHash(): string | null { 41 - const h = typeof window !== "undefined" ? window.location.hash : ""; 42 - return h.startsWith("#reply-") ? h.slice(7) : null; 43 - } 44 - 45 - function pageForRkey(refs: BacklinkRef[], rkey: string | null): number | null { 46 - if (!rkey) return null; 47 - const index = refs.findIndex((r) => r.rkey === rkey); 48 - return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null; 49 - } 50 - 51 - function clampPage(page: number, totalRefs: number): number { 52 - const totalPages = Math.max(1, Math.ceil(totalRefs / REPLIES_PER_PAGE)); 53 - return Math.max(1, Math.min(page, totalPages)); 54 - } 55 - 56 - // --- Hook --- 57 - 58 26 export function useThreadReplies(loaded: ThreadLoaderData) { 59 27 const { bbs, allRefs } = loaded; 60 28 const [params, setParams] = useSearchParams(); ··· 174 142 // Fetch records from Slingshot. 175 143 const records = await getRecordsBatch(slice); 176 144 177 - // Drop moderated and invalid content. 145 + // Drop moderated content. 178 146 const visible = records.filter((r) => { 179 147 const { did } = parseAtUri(r.uri); 180 - return ( 181 - !bbs.site.bannedDids.has(did) && 182 - !bbs.site.hiddenPosts.has(r.uri) && 183 - is(replySchema, r.value) 184 - ); 148 + return !bbs.site.bannedDids.has(did) && !bbs.site.hiddenPosts.has(r.uri); 185 149 }); 186 150 187 - // Resolve author handles. 151 + // Resolve author handles and build Reply objects. 188 152 const dids = visible.map((r) => parseAtUri(r.uri).did); 189 153 const authors = await resolveIdentitiesBatch(dids); 190 - 191 - // Build Reply objects. 192 154 const items: Reply[] = visible 193 - .filter((r) => parseAtUri(r.uri).did in authors) 194 - .map((r) => { 195 - const { did, rkey } = parseAtUri(r.uri); 196 - const v = r.value as unknown as XyzAtboardsReply.Main; 197 - return { 198 - uri: r.uri, 199 - did, 200 - rkey, 201 - handle: authors[did].handle, 202 - pds: authors[did].pds ?? "", 203 - body: v.body, 204 - createdAt: v.createdAt, 205 - quote: v.quote ?? null, 206 - attachments: (v.attachments ?? []) as Reply["attachments"], 207 - }; 208 - }); 155 + .map((r) => recordToReply(r, authors)) 156 + .filter((r): r is Reply => r !== null); 209 157 210 158 // Merge in optimistic adds that Slingshot hasn't caught up to yet. 211 159 const fetchedUris = new Set(items.map((i) => i.uri)); ··· 236 184 const quoteRecords = await getRecordsBatch(quoteRefs); 237 185 const quoteDids = quoteRecords.map((r) => parseAtUri(r.uri).did); 238 186 const quoteAuthors = await resolveIdentitiesBatch(quoteDids); 239 - for (const r of quoteRecords) { 240 - const { did, rkey } = parseAtUri(r.uri); 241 - if (!(did in quoteAuthors)) continue; 242 - if (!is(replySchema, r.value)) continue; 243 - const v = r.value as unknown as XyzAtboardsReply.Main; 244 - newCache[r.uri] = { 245 - uri: r.uri, 246 - did, 247 - rkey, 248 - handle: quoteAuthors[did].handle, 249 - pds: quoteAuthors[did].pds ?? "", 250 - body: v.body, 251 - createdAt: v.createdAt, 252 - quote: v.quote ?? null, 253 - attachments: (v.attachments ?? []) as Reply["attachments"], 254 - }; 187 + for (const record of quoteRecords) { 188 + const reply = recordToReply(record, quoteAuthors); 189 + if (reply) newCache[reply.uri] = reply; 255 190 } 256 191 } 257 192
+1 -1
web/src/hooks/useTitle.ts web/src/hooks/usePageTitle.ts
··· 1 1 import { useEffect } from "react"; 2 2 3 - export function useTitle(title: string) { 3 + export function usePageTitle(title: string) { 4 4 useEffect(() => { 5 5 const previous = document.title; 6 6 document.title = title;
+18 -17
web/src/lib/atproto.ts
··· 52 52 const unique = [...new Set(dids)]; 53 53 const results = await Promise.allSettled(unique.map(resolveIdentity)); 54 54 const map: Record<string, MiniDoc> = {}; 55 - for (const r of results) { 56 - if (r.status === "fulfilled") map[r.value.did] = r.value; 55 + for (const result of results) { 56 + if (result.status === "fulfilled") map[result.value.did] = result.value; 57 57 } 58 58 return map; 59 59 } ··· 77 77 refs: BacklinkRef[], 78 78 ): Promise<ATRecord[]> { 79 79 const results = await Promise.allSettled( 80 - refs.map((r) => getRecord(r.did, r.collection, r.rkey)), 80 + refs.map((ref) => getRecord(ref.did, ref.collection, ref.rkey)), 81 81 ); 82 82 return results 83 83 .filter( 84 - (r): r is PromiseFulfilledResult<ATRecord> => r.status === "fulfilled", 84 + (result): result is PromiseFulfilledResult<ATRecord> => 85 + result.status === "fulfilled", 85 86 ) 86 - .map((r) => r.value); 87 + .map((result) => result.value); 87 88 } 88 89 89 90 export async function getBacklinks( ··· 128 129 129 130 const records = await getRecordsBatch(backlinks.records); 130 131 131 - const filtered = records.filter((r) => { 132 - const { did } = parseAtUri(r.uri); 132 + const filtered = records.filter((record) => { 133 + const { did } = parseAtUri(record.uri); 133 134 if (opts?.excludeDid && did === opts.excludeDid) return false; 134 135 if (opts?.bannedDids?.has(did)) return false; 135 - if (opts?.hiddenPosts?.has(r.uri)) return false; 136 + if (opts?.hiddenPosts?.has(record.uri)) return false; 136 137 return true; 137 138 }); 138 139 139 140 if (!filtered.length) 140 141 return { records: [], cursor: backlinks.cursor ?? null }; 141 142 142 - const dids = filtered.map((r) => parseAtUri(r.uri).did); 143 + const dids = filtered.map((record) => parseAtUri(record.uri).did); 143 144 const authors = await resolveIdentitiesBatch(dids); 144 145 145 146 const hydrated = filtered 146 - .filter((r) => parseAtUri(r.uri).did in authors) 147 - .map((r) => { 148 - const parsed = parseAtUri(r.uri); 149 - const author = authors[parsed.did]; 147 + .filter((record) => parseAtUri(record.uri).did in authors) 148 + .map((record) => { 149 + const { did, rkey } = parseAtUri(record.uri); 150 + const author = authors[did]; 150 151 return { 151 - uri: r.uri, 152 - did: parsed.did, 153 - rkey: parsed.rkey, 152 + uri: record.uri, 153 + did, 154 + rkey, 154 155 handle: author.handle, 155 156 pds: author.pds ?? "", 156 - value: r.value, 157 + value: record.value, 157 158 }; 158 159 }); 159 160
+30 -30
web/src/lib/bbs.ts
··· 106 106 if (!is(siteSchema, siteRecord.value)) { 107 107 throw new NoBBSError(`${handle} has an invalid site record.`); 108 108 } 109 - const sv = siteRecord.value as unknown as XyzAtboardsSite.Main; 109 + const siteValue = siteRecord.value as unknown as XyzAtboardsSite.Main; 110 110 const siteUri = makeAtUri(identity.did, SITE, "self"); 111 - const boardSlugs: string[] = sv.boards ?? []; 111 + const boardSlugs: string[] = siteValue.boards ?? []; 112 112 113 113 const [boardResults, newsBacklinks, banRecords, hideRecords] = 114 114 await Promise.all([ ··· 121 121 ]); 122 122 123 123 const boards: Board[] = []; 124 - boardResults.forEach((r, i) => { 125 - if (r.status !== "fulfilled") return; 126 - if (!is(boardSchema, r.value.value)) return; 127 - const v = r.value.value as unknown as XyzAtboardsBoard.Main; 124 + boardResults.forEach((result, index) => { 125 + if (result.status !== "fulfilled") return; 126 + if (!is(boardSchema, result.value.value)) return; 127 + const board = result.value.value as unknown as XyzAtboardsBoard.Main; 128 128 boards.push({ 129 - slug: boardSlugs[i], 130 - name: v.name, 131 - description: v.description, 132 - createdAt: v.createdAt, 133 - updatedAt: v.updatedAt, 129 + slug: boardSlugs[index], 130 + name: board.name, 131 + description: board.description, 132 + createdAt: board.createdAt, 133 + updatedAt: board.updatedAt, 134 134 }); 135 135 }); 136 136 ··· 138 138 let news: News[] = []; 139 139 if (newsBacklinks) { 140 140 const sysopRefs = newsBacklinks.records.filter( 141 - (r) => r.did === identity.did, 141 + (ref) => ref.did === identity.did, 142 142 ); 143 143 const newsRecords = await getRecordsBatch(sysopRefs); 144 144 news = newsRecords 145 - .filter((r) => is(newsSchema, r.value)) 146 - .map((r) => { 147 - const v = r.value as unknown as XyzAtboardsNews.Main; 145 + .filter((record) => is(newsSchema, record.value)) 146 + .map((record) => { 147 + const value = record.value as unknown as XyzAtboardsNews.Main; 148 148 return { 149 - tid: parseAtUri(r.uri).rkey, 150 - siteUri: v.site, 151 - title: v.title, 152 - body: v.body, 153 - createdAt: v.createdAt, 154 - attachments: v.attachments as NewsAttachment[] | undefined, 149 + tid: parseAtUri(record.uri).rkey, 150 + siteUri: value.site, 151 + title: value.title, 152 + body: value.body, 153 + createdAt: value.createdAt, 154 + attachments: value.attachments as NewsAttachment[] | undefined, 155 155 }; 156 156 }); 157 157 news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); ··· 159 159 160 160 const bannedDids = new Set( 161 161 banRecords 162 - .filter((r) => is(banSchema, r.value)) 163 - .map((r) => (r.value as unknown as XyzAtboardsBan.Main).did), 162 + .filter((record) => is(banSchema, record.value)) 163 + .map((record) => (record.value as unknown as XyzAtboardsBan.Main).did), 164 164 ); 165 165 const hiddenPosts = new Set( 166 166 hideRecords 167 - .filter((r) => is(hideSchema, r.value)) 168 - .map((r) => (r.value as unknown as XyzAtboardsHide.Main).uri), 167 + .filter((record) => is(hideSchema, record.value)) 168 + .map((record) => (record.value as unknown as XyzAtboardsHide.Main).uri), 169 169 ); 170 170 171 171 return { 172 172 identity, 173 173 site: { 174 - name: sv.name, 175 - description: sv.description, 176 - intro: sv.intro, 174 + name: siteValue.name, 175 + description: siteValue.description, 176 + intro: siteValue.intro, 177 177 boards, 178 178 bannedDids, 179 179 hiddenPosts, 180 - createdAt: sv.createdAt ?? "", 181 - updatedAt: sv.updatedAt, 180 + createdAt: siteValue.createdAt ?? "", 181 + updatedAt: siteValue.updatedAt, 182 182 }, 183 183 news, 184 184 };
+16 -16
web/src/lib/inbox.ts
··· 30 30 limit: 50, 31 31 excludeDid, 32 32 }); 33 - return records.map((r) => ({ 33 + return records.map((record) => ({ 34 34 type, 35 35 threadTitle, 36 36 threadUri, 37 - replyUri: r.uri, 38 - handle: r.handle, 39 - body: ((r.value.body as string) ?? "").substring(0, 200), 40 - createdAt: (r.value.createdAt as string) ?? "", 37 + replyUri: record.uri, 38 + handle: record.handle, 39 + body: ((record.value.body as string) ?? "").substring(0, 200), 40 + createdAt: (record.value.createdAt as string) ?? "", 41 41 })); 42 42 } catch { 43 43 return []; ··· 53 53 listRecords(pdsUrl, did, THREAD, SCAN_LIMIT), 54 54 listRecords(pdsUrl, did, REPLY, SCAN_LIMIT), 55 55 ]); 56 - const threads = allThreads.filter((r) => is(threadSchema, r.value)); 57 - const replies = allReplies.filter((r) => is(replySchema, r.value)); 56 + const threads = allThreads.filter((record) => is(threadSchema, record.value)); 57 + const replies = allReplies.filter((record) => is(replySchema, record.value)); 58 58 59 59 const results = await Promise.all([ 60 - ...threads.map((tr) => { 61 - const v = tr.value as unknown as XyzAtboardsThread.Main; 60 + ...threads.map((thread) => { 61 + const value = thread.value as unknown as XyzAtboardsThread.Main; 62 62 return fetchBacklinkItems( 63 - tr.uri, 63 + thread.uri, 64 64 `${REPLY}:subject`, 65 65 did, 66 66 "reply", 67 - v.title ?? "", 68 - tr.uri, 67 + value.title ?? "", 68 + thread.uri, 69 69 ); 70 70 }), 71 - ...replies.map((rr) => { 72 - const v = rr.value as unknown as XyzAtboardsReply.Main; 71 + ...replies.map((reply) => { 72 + const value = reply.value as unknown as XyzAtboardsReply.Main; 73 73 return fetchBacklinkItems( 74 - rr.uri, 74 + reply.uri, 75 75 `${REPLY}:quote`, 76 76 did, 77 77 "quote", 78 78 "", 79 - v.subject ?? "", 79 + value.subject ?? "", 80 80 ); 81 81 }), 82 82 ]);
+65
web/src/lib/replies.ts
··· 1 + /** Pure helpers for reply pagination and hydration. */ 2 + 3 + import { type BacklinkRef } from "./atproto"; 4 + import { parseAtUri } from "./util"; 5 + import { is } from "@atcute/lexicons/validations"; 6 + import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply"; 7 + import type { XyzAtboardsReply } from "../lexicons"; 8 + import type { Reply } from "../components/ReplyCard"; 9 + 10 + export type { BacklinkRef }; 11 + 12 + export const REPLIES_PER_PAGE = 10; 13 + 14 + export function refToUri(ref: BacklinkRef): string { 15 + return `at://${ref.did}/${ref.collection}/${ref.rkey}`; 16 + } 17 + 18 + export function pageForReply( 19 + refs: BacklinkRef[], 20 + replyUri: string | null, 21 + ): number | null { 22 + if (!replyUri) return null; 23 + const index = refs.findIndex((ref) => refToUri(ref) === replyUri); 24 + return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null; 25 + } 26 + 27 + export function rkeyFromHash(): string | null { 28 + const hash = typeof window !== "undefined" ? window.location.hash : ""; 29 + return hash.startsWith("#reply-") ? hash.slice(7) : null; 30 + } 31 + 32 + export function pageForRkey( 33 + refs: BacklinkRef[], 34 + rkey: string | null, 35 + ): number | null { 36 + if (!rkey) return null; 37 + const index = refs.findIndex((ref) => ref.rkey === rkey); 38 + return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null; 39 + } 40 + 41 + export function clampPage(page: number, totalRefs: number): number { 42 + const totalPages = Math.max(1, Math.ceil(totalRefs / REPLIES_PER_PAGE)); 43 + return Math.max(1, Math.min(page, totalPages)); 44 + } 45 + 46 + export function recordToReply( 47 + record: { uri: string; value: Record<string, unknown> }, 48 + authors: Record<string, { handle: string; pds?: string }>, 49 + ): Reply | null { 50 + const { did, rkey } = parseAtUri(record.uri); 51 + if (!(did in authors)) return null; 52 + if (!is(replySchema, record.value)) return null; 53 + const value = record.value as unknown as XyzAtboardsReply.Main; 54 + return { 55 + uri: record.uri, 56 + did, 57 + rkey, 58 + handle: authors[did].handle, 59 + pds: authors[did].pds ?? "", 60 + body: value.body, 61 + createdAt: value.createdAt, 62 + quote: value.quote ?? null, 63 + attachments: (value.attachments ?? []) as Reply["attachments"], 64 + }; 65 + }
+8 -9
web/src/lib/util.ts
··· 1 1 export function formatFullDate(iso: string): string { 2 - const d = new Date(iso); 3 - const pad = (n: number) => String(n).padStart(2, "0"); 4 - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; 2 + const date = new Date(iso); 3 + const pad = (num: number) => String(num).padStart(2, "0"); 4 + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; 5 5 } 6 6 7 7 export function relativeDate(iso: string): string { 8 - const d = new Date(iso); 9 - const diff = Math.floor((Date.now() - d.getTime()) / 1000); 10 - if (diff < 60) return "just now"; 11 - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 12 - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 13 - if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; 8 + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 9 + if (seconds < 60) return "just now"; 10 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 11 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 12 + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 14 13 return formatFullDate(iso); 15 14 } 16 15
+2 -2
web/src/lib/writes.ts
··· 40 40 type Did = `did:${string}:${string}`; 41 41 type Nsid = `${string}.${string}.${string}`; 42 42 43 - const asDid = (s: string) => s as Did; 44 - const asNsid = (s: string) => s as Nsid; 43 + const asDid = (value: string) => value as Did; 44 + const asNsid = (value: string) => value as Nsid; 45 45 46 46 function currentDid(): Did { 47 47 const user = getCurrentUser();
+30 -122
web/src/pages/Account.tsx
··· 1 - import { Await, Link, useLoaderData, useRevalidator } from "react-router-dom"; 1 + import { Await, useLoaderData, useRevalidator } from "react-router-dom"; 2 2 import { Suspense, useState } from "react"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { getRecord, listRecords } from "../lib/atproto"; 5 5 import { BAN, BOARD, HIDE, NEWS, SITE } from "../lib/lexicon"; 6 - import { parseAtUri, relativeDate } from "../lib/util"; 7 - import { useTitle } from "../hooks/useTitle"; 6 + import { parseAtUri } from "../lib/util"; 7 + import { usePageTitle } from "../hooks/usePageTitle"; 8 8 import { deleteRecord } from "../lib/writes"; 9 + import InboxList from "../components/InboxList"; 10 + import BBSPanel from "../components/BBSPanel"; 9 11 import type { InboxItem } from "../router/loaders"; 10 12 import type { AuthUser } from "../lib/auth"; 11 13 ··· 15 17 bbsName: string | null; 16 18 items: Promise<InboxItem[]>; 17 19 } 18 - 19 - const PAGE_SIZE = 10; 20 20 21 21 export default function Account() { 22 22 const { user, hasBBS, bbsName, items } = useLoaderData() as LoaderData; 23 23 const { agent } = useAuth(); 24 24 const revalidator = useRevalidator(); 25 25 const [tab, setTab] = useState<"inbox" | "bbs">("inbox"); 26 - useTitle("Account — atbbs"); 26 + usePageTitle("Account — atbbs"); 27 27 28 28 async function deleteBBS() { 29 29 if (!agent) return; ··· 36 36 try { 37 37 const failed: string[] = []; 38 38 const existing = await getRecord(user.did, SITE, "self"); 39 - const sv = existing.value as Record<string, unknown>; 39 + const siteValue = existing.value as Record<string, unknown>; 40 40 const slugs: string[] = ( 41 - Array.isArray(sv.boards) ? sv.boards : [] 41 + Array.isArray(siteValue.boards) ? siteValue.boards : [] 42 42 ) as string[]; 43 - for (const s of slugs) { 43 + for (const slug of slugs) { 44 44 try { 45 - await deleteRecord(agent, BOARD, s); 45 + await deleteRecord(agent, BOARD, slug); 46 46 } catch { 47 - failed.push(`board/${s}`); 47 + failed.push(`board/${slug}`); 48 48 } 49 49 } 50 50 const newsRecords = await listRecords(user.pdsUrl, user.did, NEWS); 51 - for (const n of newsRecords) { 51 + for (const record of newsRecords) { 52 52 try { 53 - await deleteRecord(agent, NEWS, parseAtUri(n.uri).rkey); 53 + await deleteRecord(agent, NEWS, parseAtUri(record.uri).rkey); 54 54 } catch { 55 - failed.push(`news/${parseAtUri(n.uri).rkey}`); 55 + failed.push(`news/${parseAtUri(record.uri).rkey}`); 56 56 } 57 57 } 58 - for (const col of [BAN, HIDE]) { 59 - const recs = await listRecords(user.pdsUrl, user.did, col); 60 - for (const r of recs) { 58 + for (const collection of [BAN, HIDE]) { 59 + const records = await listRecords(user.pdsUrl, user.did, collection); 60 + for (const record of records) { 61 61 try { 62 - await deleteRecord(agent, col, parseAtUri(r.uri).rkey); 62 + await deleteRecord(agent, collection, parseAtUri(record.uri).rkey); 63 63 } catch { 64 - failed.push(`${col}/${parseAtUri(r.uri).rkey}`); 64 + failed.push(`${collection}/${parseAtUri(record.uri).rkey}`); 65 65 } 66 66 } 67 67 } ··· 78 78 } 79 79 } 80 80 81 + const activeTab = "py-2 border-b-2 text-neutral-200 border-neutral-200"; 82 + const inactiveTab = 83 + "py-2 border-b-2 text-neutral-500 hover:text-neutral-300 border-transparent"; 84 + 81 85 return ( 82 86 <> 83 87 <div className="mb-6"> ··· 99 103 <div className="flex gap-4 border-b border-neutral-800 mb-6"> 100 104 <button 101 105 onClick={() => setTab("inbox")} 102 - className={`py-2 border-b-2 ${tab === "inbox" ? "text-neutral-200 border-neutral-200" : "text-neutral-500 hover:text-neutral-300 border-transparent"}`} 106 + className={tab === "inbox" ? activeTab : inactiveTab} 103 107 > 104 108 Messages 105 109 </button> 106 110 <button 107 111 onClick={() => setTab("bbs")} 108 - className={`py-2 border-b-2 ${tab === "bbs" ? "text-neutral-200 border-neutral-200" : "text-neutral-500 hover:text-neutral-300 border-transparent"}`} 112 + className={tab === "bbs" ? activeTab : inactiveTab} 109 113 > 110 114 {hasBBS ? (bbsName ?? "Your BBS") : "Your BBS"} 111 115 </button> ··· 122 126 )} 123 127 124 128 {tab === "bbs" && ( 125 - <div> 126 - {hasBBS ? ( 127 - <div className="grid grid-cols-2 gap-3 max-w-md"> 128 - <Link 129 - to={`/bbs/${user.handle}`} 130 - className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-300 hover:text-neutral-200 hover:border-neutral-700" 131 - > 132 - <div className="text-neutral-200 mb-1">Browse</div> 133 - <div className="text-xs text-neutral-500">View your BBS.</div> 134 - </Link> 135 - <Link 136 - to="/account/edit" 137 - className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-300 hover:text-neutral-200 hover:border-neutral-700" 138 - > 139 - <div className="text-neutral-200 mb-1">Edit</div> 140 - <div className="text-xs text-neutral-500"> 141 - Name, boards, intro. 142 - </div> 143 - </Link> 144 - <Link 145 - to="/account/moderate" 146 - className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-300 hover:text-neutral-200 hover:border-neutral-700" 147 - > 148 - <div className="text-neutral-200 mb-1">Moderate</div> 149 - <div className="text-xs text-neutral-500"> 150 - Bans and hidden posts. 151 - </div> 152 - </Link> 153 - <button 154 - onClick={deleteBBS} 155 - className="text-left bg-neutral-900 border border-neutral-800 rounded px-4 py-3 hover:border-red-900" 156 - > 157 - <div className="text-neutral-500 mb-1">Delete</div> 158 - <div className="text-xs text-neutral-600">Remove your BBS.</div> 159 - </button> 160 - </div> 161 - ) : ( 162 - <> 163 - <p className="text-neutral-500 mb-4"> 164 - You haven't set up a BBS yet. 165 - </p> 166 - <Link 167 - to="/account/create" 168 - className="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded inline-block" 169 - > 170 - create a bbs 171 - </Link> 172 - </> 173 - )} 174 - </div> 129 + <BBSPanel 130 + hasBBS={hasBBS} 131 + userHandle={user.handle} 132 + onDelete={deleteBBS} 133 + /> 175 134 )} 176 135 </> 177 136 ); 178 137 } 179 - 180 - function InboxList({ 181 - items, 182 - userHandle, 183 - }: { 184 - items: InboxItem[]; 185 - userHandle: string; 186 - }) { 187 - const [shown, setShown] = useState(PAGE_SIZE); 188 - if (items.length === 0) 189 - return <p className="text-neutral-500">No messages yet.</p>; 190 - return ( 191 - <div> 192 - {items.slice(0, shown).map((m) => { 193 - const { did: tDid, rkey: tRkey } = parseAtUri(m.threadUri); 194 - const { rkey: replyRkey } = parseAtUri(m.replyUri); 195 - const url = `/bbs/${userHandle}/thread/${tDid}/${tRkey}#reply-${replyRkey}`; 196 - return ( 197 - <Link 198 - key={m.replyUri} 199 - to={url} 200 - className="block border border-neutral-800/50 rounded p-4 mb-2 hover:bg-neutral-800" 201 - > 202 - <div className="flex items-baseline justify-between mb-1"> 203 - <span className="text-neutral-300">{m.handle}</span> 204 - <span className="text-xs text-neutral-500"> 205 - {relativeDate(m.createdAt)} 206 - </span> 207 - </div> 208 - <p className="text-xs text-neutral-500 mb-1"> 209 - {m.type === "quote" 210 - ? "quoted your reply" 211 - : `on: ${m.threadTitle}`} 212 - </p> 213 - <p className="text-neutral-400">{m.body}</p> 214 - </Link> 215 - ); 216 - })} 217 - {shown < items.length && ( 218 - <div className="mt-4 text-center"> 219 - <button 220 - onClick={() => setShown((s) => s + PAGE_SIZE)} 221 - className="text-neutral-500 hover:text-neutral-300" 222 - > 223 - show more 224 - </button> 225 - </div> 226 - )} 227 - </div> 228 - ); 229 - }
+7 -7
web/src/pages/BBS.tsx
··· 7 7 import { NEWS, SITE } from "../lib/lexicon"; 8 8 import { makeAtUri, nowIso, parseAtUri } from "../lib/util"; 9 9 import * as limits from "../lib/limits"; 10 - import { useTitle } from "../hooks/useTitle"; 10 + import { usePageTitle } from "../hooks/usePageTitle"; 11 11 import Localtime from "../components/Localtime"; 12 12 import ListLink from "../components/ListLink"; 13 13 import type { News } from "../lib/bbs"; ··· 28 28 [{ label: bbs.site.name, to: `/bbs/${handle}` }], 29 29 [bbs, handle], 30 30 ); 31 - useTitle(`${bbs.site.name} — atbbs`); 31 + usePageTitle(`${bbs.site.name} — atbbs`); 32 32 33 33 if (user && bbs.site.bannedDids.has(user.did)) 34 34 return ( ··· 91 91 Boards 92 92 </h2> 93 93 <div className="space-y-1"> 94 - {bbs.site.boards.map((b) => ( 94 + {bbs.site.boards.map((board) => ( 95 95 <ListLink 96 - key={b.slug} 97 - to={`/bbs/${handle}/board/${b.slug}`} 98 - name={b.name} 99 - description={b.description} 96 + key={board.slug} 97 + to={`/bbs/${handle}/board/${board.slug}`} 98 + name={board.name} 99 + description={board.description} 100 100 /> 101 101 ))} 102 102 </div>
+2 -2
web/src/pages/Board.tsx
··· 7 7 } from "react-router-dom"; 8 8 import { useAuth } from "../lib/auth"; 9 9 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 10 - import { useTitle } from "../hooks/useTitle"; 10 + import { usePageTitle } from "../hooks/usePageTitle"; 11 11 import { makeAtUri, parseAtUri, relativeDate } from "../lib/util"; 12 12 import { BOARD } from "../lib/lexicon"; 13 13 import { createThread, uploadAttachments } from "../lib/writes"; ··· 51 51 const [body, setBody] = useState(""); 52 52 const [files, setFiles] = useState<File[]>([]); 53 53 54 - useTitle(`${board.name} — ${bbs.site.name}`); 54 + usePageTitle(`${board.name} — ${bbs.site.name}`); 55 55 useBreadcrumb( 56 56 [ 57 57 { label: bbs.site.name, to: `/bbs/${handle}` },
+12 -12
web/src/pages/Home.tsx
··· 4 4 import ListLink from "../components/ListLink"; 5 5 import { resolveIdentitiesBatch } from "../lib/atproto"; 6 6 import { SITE } from "../lib/lexicon"; 7 - import { useTitle } from "../hooks/useTitle"; 7 + import { usePageTitle } from "../hooks/usePageTitle"; 8 8 9 9 interface UFORecord { 10 10 did: string; ··· 21 21 const DISCOVERY_TTL = 5 * 60 * 1000; // 5 minutes 22 22 23 23 export default function Home() { 24 - const nav = useNavigate(); 24 + const navigate = useNavigate(); 25 25 const [handle, setHandle] = useState(""); 26 26 const [tab, setTab] = useState<"pip" | "uv" | "brew" | "telnet">("pip"); 27 27 const [discovered, setDiscovered] = useState<Discovered[]>([]); 28 - useTitle("atbbs"); 28 + usePageTitle("atbbs"); 29 29 30 30 function onSubmit(e: SyntheticEvent) { 31 31 e.preventDefault(); 32 - const h = handle.trim(); 33 - if (h) nav(`/bbs/${encodeURIComponent(h)}`); 32 + const trimmed = handle.trim(); 33 + if (trimmed) navigate(`/bbs/${encodeURIComponent(trimmed)}`); 34 34 } 35 35 36 36 function onRandom() { 37 37 if (!discovered.length) return; 38 - const d = discovered[Math.floor(Math.random() * discovered.length)]; 39 - nav(`/bbs/${encodeURIComponent(d.handle)}`); 38 + const pick = discovered[Math.floor(Math.random() * discovered.length)]; 39 + navigate(`/bbs/${encodeURIComponent(pick.handle)}`); 40 40 } 41 41 42 42 useEffect(() => { ··· 132 132 or try one of these 133 133 </p> 134 134 <div className="space-y-1"> 135 - {discovered.slice(0, 5).map((d) => ( 135 + {discovered.slice(0, 5).map((bbs) => ( 136 136 <ListLink 137 - key={d.handle} 138 - to={`/bbs/${encodeURIComponent(d.handle)}`} 139 - name={d.name} 140 - description={d.desc} 137 + key={bbs.handle} 138 + to={`/bbs/${encodeURIComponent(bbs.handle)}`} 139 + name={bbs.name} 140 + description={bbs.desc} 141 141 /> 142 142 ))} 143 143 </div>
+2 -2
web/src/pages/Login.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 2 import { useAuth } from "../lib/auth"; 3 - import { useTitle } from "../hooks/useTitle"; 3 + import { usePageTitle } from "../hooks/usePageTitle"; 4 4 import HandleInput from "../components/HandleInput"; 5 5 6 6 export default function Login() { ··· 8 8 const [handle, setHandle] = useState(""); 9 9 const [error, setError] = useState<string | null>(null); 10 10 const [busy, setBusy] = useState(false); 11 - useTitle("Login — atbbs"); 11 + usePageTitle("Login — atbbs"); 12 12 13 13 async function onSubmit(e: SyntheticEvent) { 14 14 e.preventDefault();
+15 -26
web/src/pages/News.tsx
··· 1 1 import { useNavigate, useParams, useRouteLoaderData } from "react-router-dom"; 2 2 import { useAuth } from "../lib/auth"; 3 3 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 4 - import { useTitle } from "../hooks/useTitle"; 5 - import { formatFullDate, relativeDate } from "../lib/util"; 4 + import { usePageTitle } from "../hooks/usePageTitle"; 6 5 import { NEWS } from "../lib/lexicon"; 7 6 import { deleteRecord } from "../lib/writes"; 8 7 import type { BBSLoaderData } from "../router/loaders"; 9 8 import AttachmentLink from "../components/AttachmentLink"; 9 + import PostActions from "../components/PostActions"; 10 10 import PostBody from "../components/PostBody"; 11 + import PostMeta from "../components/PostMeta"; 11 12 12 13 export default function NewsPage() { 13 14 const { handle, tid } = useParams(); ··· 24 25 ], 25 26 [bbs, handle, tid], 26 27 ); 27 - useTitle( 28 + usePageTitle( 28 29 item ? `${item.title} — ${bbs.site.name}` : `News — ${bbs.site.name}`, 29 30 ); 30 31 ··· 32 33 return <p className="text-neutral-500">News post not found.</p>; 33 34 } 34 35 35 - const isSysop = user && user.did === bbs.identity.did; 36 + const isSysop = !!(user && user.did === bbs.identity.did); 36 37 37 38 async function onDelete() { 38 39 if (!agent || !tid) return; ··· 44 45 return ( 45 46 <article className="bg-neutral-900 border border-neutral-800 rounded p-4"> 46 47 <div className="flex items-baseline justify-between mb-3"> 47 - <div className="flex items-baseline gap-2"> 48 - <span className="text-neutral-200">{handle}</span> 49 - <span className="text-neutral-600">·</span> 50 - <time 51 - className="text-xs text-neutral-500" 52 - title={formatFullDate(item.createdAt)} 53 - > 54 - {relativeDate(item.createdAt)} 55 - </time> 56 - </div> 57 - {isSysop && ( 58 - <button 59 - onClick={onDelete} 60 - className="text-xs text-neutral-500 hover:text-red-400" 61 - > 62 - delete 63 - </button> 64 - )} 48 + <PostMeta handle={handle ?? ""} createdAt={item.createdAt} /> 49 + <PostActions 50 + isAuthor={isSysop} 51 + isSysop={false} 52 + onDelete={onDelete} 53 + /> 65 54 </div> 66 55 <h1 className="text-lg text-neutral-200 font-bold mb-3">{item.title}</h1> 67 56 <PostBody>{item.body}</PostBody> 68 57 {item.attachments && item.attachments.length > 0 && ( 69 58 <div className="mt-3 space-y-1"> 70 - {item.attachments.map((a, i) => ( 59 + {item.attachments.map((attachment, index) => ( 71 60 <AttachmentLink 72 - key={i} 61 + key={index} 73 62 pds={bbs.identity.pds ?? ""} 74 63 did={bbs.identity.did} 75 - cid={a.file.ref.$link} 76 - name={a.name} 64 + cid={attachment.file.ref.$link} 65 + name={attachment.name} 77 66 /> 78 67 ))} 79 68 </div>
+3 -3
web/src/pages/OAuthCallback.tsx
··· 4 4 5 5 /** Handles the OAuth redirect: exchanges params for a session. */ 6 6 export default function OAuthCallback() { 7 - const nav = useNavigate(); 7 + const navigate = useNavigate(); 8 8 const [error, setError] = useState<string | null>(null); 9 9 10 10 useEffect(() => { 11 11 completeAuthCallback() 12 12 .then(() => { 13 13 const dest = takePostLoginRedirect() ?? "/"; 14 - nav(dest, { replace: true }); 14 + navigate(dest, { replace: true }); 15 15 }) 16 16 .catch((e) => { 17 17 console.error(e); 18 18 setError(e?.message ?? "Sign-in failed."); 19 19 }); 20 - }, [nav]); 20 + }, [navigate]); 21 21 22 22 if (error) return <p className="text-red-400">{error}</p>; 23 23 return <p className="text-neutral-500">Signing in…</p>;
+10 -10
web/src/pages/SysopCreate.tsx
··· 4 4 import { putBoard, putSite } from "../lib/writes"; 5 5 import { nowIso } from "../lib/util"; 6 6 import * as limits from "../lib/limits"; 7 - import { useTitle } from "../hooks/useTitle"; 7 + import { usePageTitle } from "../hooks/usePageTitle"; 8 8 import { Input, Textarea, Button } from "../components/Form"; 9 9 import BoardRowEditor, { type BoardRow } from "../components/BoardRowEditor"; 10 10 import type { AuthUser } from "../lib/auth"; ··· 26 26 ]); 27 27 const [error, setError] = useState<string | null>(null); 28 28 29 - useTitle("Create BBS — atbbs"); 29 + usePageTitle("Create BBS — atbbs"); 30 30 31 31 async function onSubmit(e: SyntheticEvent) { 32 32 e.preventDefault(); 33 33 if (!agent) return; 34 34 const cleanBoards = boards 35 - .map((b) => ({ 36 - slug: b.slug.trim(), 37 - name: b.name.trim(), 38 - desc: b.desc.trim(), 35 + .map((board) => ({ 36 + slug: board.slug.trim(), 37 + name: board.name.trim(), 38 + desc: board.desc.trim(), 39 39 })) 40 - .filter((b) => b.slug); 40 + .filter((board) => board.slug); 41 41 if (!name.trim() || !cleanBoards.length) { 42 42 setError("Name and at least one board are required."); 43 43 return; 44 44 } 45 45 const now = nowIso(); 46 46 try { 47 - for (const b of cleanBoards) { 48 - await putBoard(agent, b.slug, b.name || b.slug, b.desc, now); 47 + for (const board of cleanBoards) { 48 + await putBoard(agent, board.slug, board.name || board.slug, board.desc, now); 49 49 } 50 50 await putSite(agent, { 51 51 name: name.trim(), 52 52 description: description.trim(), 53 53 intro, 54 - boards: cleanBoards.map((b) => b.slug), 54 + boards: cleanBoards.map((board) => board.slug), 55 55 createdAt: now, 56 56 }); 57 57 navigate(`/bbs/${user.handle}`);
+14 -14
web/src/pages/SysopEdit.tsx
··· 4 4 import { putBoard, putSite } from "../lib/writes"; 5 5 import { nowIso } from "../lib/util"; 6 6 import * as limits from "../lib/limits"; 7 - import { useTitle } from "../hooks/useTitle"; 7 + import { usePageTitle } from "../hooks/usePageTitle"; 8 8 import { Input, Textarea, Button } from "../components/Form"; 9 9 import BoardRowEditor, { type BoardRow } from "../components/BoardRowEditor"; 10 10 import type { BBS } from "../lib/bbs"; ··· 24 24 const [description, setDescription] = useState(bbs.site.description); 25 25 const [intro, setIntro] = useState(bbs.site.intro); 26 26 const [boards, setBoards] = useState<BoardRow[]>( 27 - bbs.site.boards.map((b) => ({ 28 - slug: b.slug, 29 - name: b.name, 30 - desc: b.description, 27 + bbs.site.boards.map((board) => ({ 28 + slug: board.slug, 29 + name: board.name, 30 + desc: board.description, 31 31 })), 32 32 ); 33 33 const [error, setError] = useState<string | null>(null); 34 34 35 - useTitle("Edit BBS — atbbs"); 35 + usePageTitle("Edit BBS — atbbs"); 36 36 37 37 async function onSubmit(e: SyntheticEvent) { 38 38 e.preventDefault(); 39 39 if (!agent || !name.trim()) return; 40 40 const cleanBoards = boards 41 - .map((b) => ({ 42 - slug: b.slug.trim(), 43 - name: b.name.trim(), 44 - desc: b.desc.trim(), 41 + .map((board) => ({ 42 + slug: board.slug.trim(), 43 + name: board.name.trim(), 44 + desc: board.desc.trim(), 45 45 })) 46 - .filter((b) => b.slug); 46 + .filter((board) => board.slug); 47 47 const now = nowIso(); 48 48 try { 49 - for (const b of cleanBoards) { 50 - await putBoard(agent, b.slug, b.name || b.slug, b.desc, now); 49 + for (const board of cleanBoards) { 50 + await putBoard(agent, board.slug, board.name || board.slug, board.desc, now); 51 51 } 52 52 await putSite(agent, { 53 53 name: name.trim(), 54 54 description: description.trim(), 55 55 intro, 56 - boards: cleanBoards.map((b) => b.slug), 56 + boards: cleanBoards.map((board) => board.slug), 57 57 createdAt: bbs.site.createdAt || now, 58 58 updatedAt: now, 59 59 });
+2 -2
web/src/pages/SysopModerate.tsx
··· 5 5 import { BAN, HIDE } from "../lib/lexicon"; 6 6 import { invalidateBBSCache } from "../lib/bbs"; 7 7 import HandleInput from "../components/HandleInput"; 8 - import { useTitle } from "../hooks/useTitle"; 8 + import { usePageTitle } from "../hooks/usePageTitle"; 9 9 import { createBan, createHide, deleteRecord } from "../lib/writes"; 10 10 import type { BBS } from "../lib/bbs"; 11 11 import type { AuthUser } from "../lib/auth"; ··· 27 27 const revalidator = useRevalidator(); 28 28 const [identifier, setIdentifier] = useState(""); 29 29 const [hideUri, setHideUri] = useState(""); 30 - useTitle("Moderate BBS — atbbs"); 30 + usePageTitle("Moderate BBS — atbbs"); 31 31 32 32 async function ban() { 33 33 if (!agent) return;
+8 -8
web/src/pages/Thread.tsx
··· 7 7 } from "react-router-dom"; 8 8 import { useAuth } from "../lib/auth"; 9 9 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 10 - import { useTitle } from "../hooks/useTitle"; 10 + import { usePageTitle } from "../hooks/usePageTitle"; 11 11 import { useThreadReplies } from "../hooks/useThreadReplies"; 12 12 import { THREAD, REPLY } from "../lib/lexicon"; 13 13 import { makeAtUri, parseAtUri } from "../lib/util"; ··· 23 23 import PageNav from "../components/PageNav"; 24 24 import ReplyCard, { type Reply } from "../components/ReplyCard"; 25 25 import ComposeForm from "../components/ComposeForm"; 26 - import ThreadHeader from "../components/ThreadHeader"; 26 + import ThreadCard from "../components/ThreadCard"; 27 27 28 28 interface LoaderData { 29 29 handle: string; ··· 70 70 ); 71 71 const [posting, setPosting] = useState(false); 72 72 73 - useTitle(`${thread.title} — ${bbs.site.name}`); 73 + usePageTitle(`${thread.title} — ${bbs.site.name}`); 74 74 useBreadcrumb(buildBreadcrumb(bbs, thread, handle), [bbs, thread, handle]); 75 75 76 76 const isSysop = user && user.did === bbs.identity.did; ··· 147 147 148 148 return ( 149 149 <> 150 - <ThreadHeader 150 + <ThreadCard 151 151 thread={thread} 152 152 userDid={user?.did} 153 153 sysopDid={bbs.identity.did} 154 - onDeleteThread={onDeleteThread} 155 - onBanAuthor={() => onBan(thread.did)} 156 - onHideThread={() => onHide(thread.uri)} 154 + onDelete={onDeleteThread} 155 + onBan={() => onBan(thread.did)} 156 + onHide={() => onHide(thread.uri)} 157 157 /> 158 158 159 159 {totalPages > 1 && ( ··· 217 217 thread: ThreadObj, 218 218 handle: string, 219 219 ) { 220 - const board = bbs.site.boards.find((b) => b.slug === thread.boardSlug); 220 + const board = bbs.site.boards.find((board) => board.slug === thread.boardSlug); 221 221 return [ 222 222 { label: bbs.site.name, to: `/bbs/${handle}` }, 223 223 ...(board
-298
web/src/router/loaders.ts
··· 1 - /** Route loaders. Pages read these via useLoaderData(). */ 2 - 3 - import { redirect, type LoaderFunctionArgs } from "react-router-dom"; 4 - import { ensureAuthReady, getCurrentUser } from "../lib/auth"; 5 - import { resolveBBS, type BBS } from "../lib/bbs"; 6 - import { fetchInbox } from "../lib/inbox"; 7 - import { 8 - getRecord, 9 - getRecordByUri, 10 - getBacklinks, 11 - getRecordsBatch, 12 - listRecords, 13 - resolveIdentitiesBatch, 14 - resolveIdentity, 15 - type ATRecord, 16 - type BacklinkRef, 17 - } from "../lib/atproto"; 18 - import { SITE, THREAD, REPLY, BAN, HIDE, BOARD } from "../lib/lexicon"; 19 - import { makeAtUri, parseAtUri } from "../lib/util"; 20 - import { is } from "@atcute/lexicons/validations"; 21 - import { mainSchema as threadSchema } from "../lexicons/types/xyz/atboards/thread"; 22 - import { mainSchema as banSchema } from "../lexicons/types/xyz/atboards/ban"; 23 - import { mainSchema as hideSchema } from "../lexicons/types/xyz/atboards/hide"; 24 - import type { 25 - XyzAtboardsThread, 26 - XyzAtboardsBan, 27 - XyzAtboardsHide, 28 - } from "../lexicons"; 29 - 30 - // --- Auth guard (shared by all protected loaders) --- 31 - 32 - async function requireAuth() { 33 - await ensureAuthReady(); 34 - const user = getCurrentUser(); 35 - if (!user) throw redirect("/login"); 36 - return user; 37 - } 38 - 39 - // --- BBS parent --- 40 - 41 - export async function bbsLoader({ params }: LoaderFunctionArgs) { 42 - const handle = params.handle!; 43 - const bbs = await resolveBBS(handle); 44 - return { handle, bbs }; 45 - } 46 - 47 - export type BBSLoaderData = { handle: string; bbs: BBS }; 48 - 49 - // --- Board --- 50 - 51 - export interface ThreadItem { 52 - uri: string; 53 - did: string; 54 - rkey: string; 55 - handle: string; 56 - title: string; 57 - body: string; 58 - createdAt: string; 59 - } 60 - 61 - /** Fetch one page of threads for a board, hydrated and filtered. */ 62 - export async function hydrateThreadPage( 63 - bbs: BBS, 64 - slug: string, 65 - cursor?: string, 66 - ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { 67 - const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); 68 - const backlinks = await getBacklinks(boardUri, `${THREAD}:board`, 50, cursor); 69 - const records = await getRecordsBatch(backlinks.records); 70 - const filtered = records.filter((r) => { 71 - const { did } = parseAtUri(r.uri); 72 - return ( 73 - !bbs.site.bannedDids.has(did) && 74 - !bbs.site.hiddenPosts.has(r.uri) && 75 - is(threadSchema, r.value) 76 - ); 77 - }); 78 - const authors = await resolveIdentitiesBatch( 79 - filtered.map((r) => parseAtUri(r.uri).did), 80 - ); 81 - const threads: ThreadItem[] = filtered 82 - .filter((r) => parseAtUri(r.uri).did in authors) 83 - .map((r: ATRecord) => { 84 - const { did, rkey } = parseAtUri(r.uri); 85 - const v = r.value as unknown as XyzAtboardsThread.Main; 86 - return { 87 - uri: r.uri, 88 - did, 89 - rkey, 90 - handle: authors[did].handle, 91 - title: v.title, 92 - body: v.body, 93 - createdAt: v.createdAt, 94 - }; 95 - }) 96 - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 97 - return { threads, cursor: backlinks.cursor ?? null }; 98 - } 99 - 100 - export async function boardLoader({ params }: LoaderFunctionArgs) { 101 - const handle = params.handle!; 102 - const slug = params.slug!; 103 - const bbs = await resolveBBS(handle); 104 - const board = bbs.site.boards.find((b) => b.slug === slug); 105 - if (!board) throw new Response("Board not found", { status: 404 }); 106 - 107 - const { threads, cursor } = await hydrateThreadPage(bbs, slug); 108 - return { handle, bbs, board, threads, cursor }; 109 - } 110 - 111 - // --- Thread --- 112 - 113 - export interface ThreadObj { 114 - uri: string; 115 - did: string; 116 - rkey: string; 117 - authorHandle: string; 118 - authorPds: string; 119 - title: string; 120 - body: string; 121 - createdAt: string; 122 - boardSlug: string; 123 - attachments?: { file: { ref: { $link: string } }; name: string }[]; 124 - } 125 - 126 - export async function threadLoader({ params }: LoaderFunctionArgs) { 127 - const handle = params.handle!; 128 - const did = params.did!; 129 - const tid = params.tid!; 130 - 131 - const threadUri = makeAtUri(did, THREAD, tid); 132 - const [bbs, tr, author, allRefs] = await Promise.all([ 133 - resolveBBS(handle), 134 - getRecord(did, THREAD, tid), 135 - resolveIdentity(did), 136 - collectAllReplyRefs(threadUri), 137 - ]); 138 - if (!is(threadSchema, tr.value)) { 139 - throw new Response("Invalid thread record", { status: 404 }); 140 - } 141 - const tv = tr.value as unknown as XyzAtboardsThread.Main; 142 - const boardSlug = parseAtUri(tv.board).rkey; 143 - const thread: ThreadObj = { 144 - uri: tr.uri, 145 - did, 146 - rkey: tid, 147 - authorHandle: author.handle, 148 - authorPds: author.pds ?? "", 149 - title: tv.title, 150 - body: tv.body, 151 - createdAt: tv.createdAt, 152 - boardSlug, 153 - attachments: tv.attachments as ThreadObj["attachments"], 154 - }; 155 - 156 - return { handle, bbs, thread, allRefs }; 157 - } 158 - 159 - // --- Account --- 160 - 161 - export type { InboxItem } from "../lib/inbox"; 162 - 163 - /** Collect all reply refs, paginating Constellation in chunks of 100. */ 164 - async function collectAllReplyRefs(threadUri: string): Promise<BacklinkRef[]> { 165 - const collected: BacklinkRef[] = []; 166 - let cursor: string | undefined; 167 - for (let i = 0; i < 20; i++) { 168 - const page = await getBacklinks(threadUri, `${REPLY}:subject`, 100, cursor); 169 - collected.push(...page.records); 170 - if (!page.cursor) break; 171 - cursor = page.cursor; 172 - } 173 - return collected.reverse(); // oldest first 174 - } 175 - 176 - export async function accountLoader() { 177 - const user = await requireAuth(); 178 - 179 - // Probe site record (fast — keep awaited so the page can render with it) 180 - let hasBBS = false; 181 - let bbsName: string | null = null; 182 - try { 183 - const r = await getRecord(user.did, SITE, "self"); 184 - hasBBS = true; 185 - const sv = r.value as unknown as { name?: string }; 186 - bbsName = sv.name ?? user.handle; 187 - } catch { 188 - // no site 189 - } 190 - 191 - // Inbox lookup is slow — return the promise unawaited so the page renders 192 - // immediately and items stream in via <Await>. (v7 auto-defers promises.) 193 - const itemsPromise = fetchInbox(user.did, user.pdsUrl); 194 - return { user, hasBBS, bbsName, items: itemsPromise }; 195 - } 196 - 197 - // --- Sysop --- 198 - 199 - export async function requireAuthLoader() { 200 - return { user: await requireAuth() }; 201 - } 202 - 203 - export async function sysopEditLoader() { 204 - const user = await requireAuth(); 205 - try { 206 - const bbs = await resolveBBS(user.handle); 207 - return { user, bbs }; 208 - } catch { 209 - throw redirect("/account/create"); 210 - } 211 - } 212 - 213 - export interface HiddenInfo { 214 - uri: string; 215 - handle: string; 216 - title: string; 217 - body: string; 218 - } 219 - 220 - /** Build a map from a record field value to its rkey, for deletion. */ 221 - function buildRkeyMap<T>( 222 - records: { uri: string; value: Record<string, unknown> }[], 223 - schema: Parameters<typeof is>[0], 224 - getKey: (v: T) => string, 225 - ): Record<string, string> { 226 - const map: Record<string, string> = {}; 227 - for (const r of records) { 228 - if (!is(schema, r.value)) continue; 229 - map[getKey(r.value as unknown as T)] = parseAtUri(r.uri).rkey; 230 - } 231 - return map; 232 - } 233 - 234 - async function hydrateHiddenPosts(uris: Set<string>): Promise<HiddenInfo[]> { 235 - const hidden: HiddenInfo[] = []; 236 - for (const uri of uris) { 237 - const did = parseAtUri(uri).did; 238 - let handle = did; 239 - try { 240 - handle = (await resolveIdentity(did)).handle; 241 - } catch {} 242 - try { 243 - const rec = await getRecordByUri(uri); 244 - const v = rec.value as unknown as { title?: string; body?: string }; 245 - hidden.push({ 246 - uri, 247 - handle, 248 - title: v.title ?? "", 249 - body: (v.body ?? "").substring(0, 100), 250 - }); 251 - } catch { 252 - hidden.push({ uri, handle, title: "", body: uri }); 253 - } 254 - } 255 - return hidden; 256 - } 257 - 258 - export async function sysopModerateLoader() { 259 - const user = await requireAuth(); 260 - 261 - let bbs: BBS; 262 - try { 263 - bbs = await resolveBBS(user.handle); 264 - } catch { 265 - throw redirect("/account/create"); 266 - } 267 - 268 - const [banRecs, hideRecs] = await Promise.all([ 269 - listRecords(user.pdsUrl, user.did, BAN), 270 - listRecords(user.pdsUrl, user.did, HIDE), 271 - ]); 272 - 273 - const banRkeys = buildRkeyMap<XyzAtboardsBan.Main>( 274 - banRecs, 275 - banSchema, 276 - (v) => v.did, 277 - ); 278 - const hideRkeys = buildRkeyMap<XyzAtboardsHide.Main>( 279 - hideRecs, 280 - hideSchema, 281 - (v) => v.uri, 282 - ); 283 - 284 - let bannedHandles: Record<string, string> = {}; 285 - if (bbs.site.bannedDids.size) { 286 - try { 287 - const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]); 288 - for (const did of bbs.site.bannedDids) 289 - bannedHandles[did] = authors[did]?.handle ?? did; 290 - } catch { 291 - for (const did of bbs.site.bannedDids) bannedHandles[did] = did; 292 - } 293 - } 294 - 295 - const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts); 296 - 297 - return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden }; 298 - }
+28
web/src/router/loaders/account.ts
··· 1 + import { getRecord } from "../../lib/atproto"; 2 + import { fetchInbox } from "../../lib/inbox"; 3 + import { SITE } from "../../lib/lexicon"; 4 + import { requireAuth } from "./auth"; 5 + 6 + export type { InboxItem } from "../../lib/inbox"; 7 + 8 + export async function accountLoader() { 9 + const user = await requireAuth(); 10 + 11 + let hasBBS = false; 12 + let bbsName: string | null = null; 13 + try { 14 + const siteRecord = await getRecord(user.did, SITE, "self"); 15 + hasBBS = true; 16 + const siteValue = siteRecord.value as unknown as { name?: string }; 17 + bbsName = siteValue.name ?? user.handle; 18 + } catch { 19 + // no site 20 + } 21 + 22 + const itemsPromise = fetchInbox(user.did, user.pdsUrl); 23 + return { user, hasBBS, bbsName, items: itemsPromise }; 24 + } 25 + 26 + export async function requireAuthLoader() { 27 + return { user: await requireAuth() }; 28 + }
+9
web/src/router/loaders/auth.ts
··· 1 + import { redirect } from "react-router-dom"; 2 + import { ensureAuthReady, getCurrentUser } from "../../lib/auth"; 3 + 4 + export async function requireAuth() { 5 + await ensureAuthReady(); 6 + const user = getCurrentUser(); 7 + if (!user) throw redirect("/login"); 8 + return user; 9 + }
+10
web/src/router/loaders/bbs.ts
··· 1 + import type { LoaderFunctionArgs } from "react-router-dom"; 2 + import { resolveBBS, type BBS } from "../../lib/bbs"; 3 + 4 + export async function bbsLoader({ params }: LoaderFunctionArgs) { 5 + const handle = params.handle!; 6 + const bbs = await resolveBBS(handle); 7 + return { handle, bbs }; 8 + } 9 + 10 + export type BBSLoaderData = { handle: string; bbs: BBS };
+72
web/src/router/loaders/board.ts
··· 1 + import type { LoaderFunctionArgs } from "react-router-dom"; 2 + import { resolveBBS, type BBS } from "../../lib/bbs"; 3 + import { 4 + getBacklinks, 5 + getRecordsBatch, 6 + resolveIdentitiesBatch, 7 + type ATRecord, 8 + } from "../../lib/atproto"; 9 + import { THREAD, BOARD } from "../../lib/lexicon"; 10 + import { makeAtUri, parseAtUri } from "../../lib/util"; 11 + import { is } from "@atcute/lexicons/validations"; 12 + import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread"; 13 + import type { XyzAtboardsThread } from "../../lexicons"; 14 + 15 + export interface ThreadItem { 16 + uri: string; 17 + did: string; 18 + rkey: string; 19 + handle: string; 20 + title: string; 21 + body: string; 22 + createdAt: string; 23 + } 24 + 25 + export async function hydrateThreadPage( 26 + bbs: BBS, 27 + slug: string, 28 + cursor?: string, 29 + ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { 30 + const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); 31 + const backlinks = await getBacklinks(boardUri, `${THREAD}:board`, 50, cursor); 32 + const records = await getRecordsBatch(backlinks.records); 33 + const filtered = records.filter((record) => { 34 + const { did } = parseAtUri(record.uri); 35 + return ( 36 + !bbs.site.bannedDids.has(did) && 37 + !bbs.site.hiddenPosts.has(record.uri) && 38 + is(threadSchema, record.value) 39 + ); 40 + }); 41 + const authors = await resolveIdentitiesBatch( 42 + filtered.map((record) => parseAtUri(record.uri).did), 43 + ); 44 + const threads: ThreadItem[] = filtered 45 + .filter((record) => parseAtUri(record.uri).did in authors) 46 + .map((record: ATRecord) => { 47 + const { did, rkey } = parseAtUri(record.uri); 48 + const value = record.value as unknown as XyzAtboardsThread.Main; 49 + return { 50 + uri: record.uri, 51 + did, 52 + rkey, 53 + handle: authors[did].handle, 54 + title: value.title, 55 + body: value.body, 56 + createdAt: value.createdAt, 57 + }; 58 + }) 59 + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 60 + return { threads, cursor: backlinks.cursor ?? null }; 61 + } 62 + 63 + export async function boardLoader({ params }: LoaderFunctionArgs) { 64 + const handle = params.handle!; 65 + const slug = params.slug!; 66 + const bbs = await resolveBBS(handle); 67 + const board = bbs.site.boards.find((board) => board.slug === slug); 68 + if (!board) throw new Response("Board not found", { status: 404 }); 69 + 70 + const { threads, cursor } = await hydrateThreadPage(bbs, slug); 71 + return { handle, bbs, board, threads, cursor }; 72 + }
+13
web/src/router/loaders/index.ts
··· 1 + export { bbsLoader, type BBSLoaderData } from "./bbs"; 2 + export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board"; 3 + export { threadLoader, type ThreadObj } from "./thread"; 4 + export { 5 + accountLoader, 6 + requireAuthLoader, 7 + type InboxItem, 8 + } from "./account"; 9 + export { 10 + sysopEditLoader, 11 + sysopModerateLoader, 12 + type HiddenInfo, 13 + } from "./sysop";
+111
web/src/router/loaders/sysop.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 + resolveIdentity, 8 + } from "../../lib/atproto"; 9 + import { BAN, HIDE } from "../../lib/lexicon"; 10 + import { parseAtUri } from "../../lib/util"; 11 + import { is } from "@atcute/lexicons/validations"; 12 + import { mainSchema as banSchema } from "../../lexicons/types/xyz/atboards/ban"; 13 + import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atboards/hide"; 14 + import type { XyzAtboardsBan, XyzAtboardsHide } from "../../lexicons"; 15 + import { requireAuth } from "./auth"; 16 + 17 + export interface HiddenInfo { 18 + uri: string; 19 + handle: string; 20 + title: string; 21 + body: string; 22 + } 23 + 24 + function buildRkeyMap<T>( 25 + records: { uri: string; value: Record<string, unknown> }[], 26 + schema: Parameters<typeof is>[0], 27 + getKey: (value: T) => string, 28 + ): Record<string, string> { 29 + const map: Record<string, string> = {}; 30 + for (const record of records) { 31 + if (!is(schema, record.value)) continue; 32 + map[getKey(record.value as unknown as T)] = parseAtUri(record.uri).rkey; 33 + } 34 + return map; 35 + } 36 + 37 + async function hydrateHiddenPosts(uris: Set<string>): Promise<HiddenInfo[]> { 38 + const hidden: HiddenInfo[] = []; 39 + for (const uri of uris) { 40 + const did = parseAtUri(uri).did; 41 + let handle = did; 42 + try { 43 + handle = (await resolveIdentity(did)).handle; 44 + } catch {} 45 + try { 46 + const record = await getRecordByUri(uri); 47 + const value = record.value as unknown as { title?: string; body?: string }; 48 + hidden.push({ 49 + uri, 50 + handle, 51 + title: value.title ?? "", 52 + body: (value.body ?? "").substring(0, 100), 53 + }); 54 + } catch { 55 + hidden.push({ uri, handle, title: "", body: uri }); 56 + } 57 + } 58 + return hidden; 59 + } 60 + 61 + export async function sysopEditLoader() { 62 + const user = await requireAuth(); 63 + try { 64 + const bbs = await resolveBBS(user.handle); 65 + return { user, bbs }; 66 + } catch { 67 + throw redirect("/account/create"); 68 + } 69 + } 70 + 71 + export async function sysopModerateLoader() { 72 + const user = await requireAuth(); 73 + 74 + let bbs: BBS; 75 + try { 76 + bbs = await resolveBBS(user.handle); 77 + } catch { 78 + throw redirect("/account/create"); 79 + } 80 + 81 + const [banRecs, hideRecs] = await Promise.all([ 82 + listRecords(user.pdsUrl, user.did, BAN), 83 + listRecords(user.pdsUrl, user.did, HIDE), 84 + ]); 85 + 86 + const banRkeys = buildRkeyMap<XyzAtboardsBan.Main>( 87 + banRecs, 88 + banSchema, 89 + (ban) => ban.did, 90 + ); 91 + const hideRkeys = buildRkeyMap<XyzAtboardsHide.Main>( 92 + hideRecs, 93 + hideSchema, 94 + (hide) => hide.uri, 95 + ); 96 + 97 + let bannedHandles: Record<string, string> = {}; 98 + if (bbs.site.bannedDids.size) { 99 + try { 100 + const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]); 101 + for (const did of bbs.site.bannedDids) 102 + bannedHandles[did] = authors[did]?.handle ?? did; 103 + } catch { 104 + for (const did of bbs.site.bannedDids) bannedHandles[did] = did; 105 + } 106 + } 107 + 108 + const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts); 109 + 110 + return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden }; 111 + }
+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 { THREAD, REPLY } from "../../lib/lexicon"; 10 + import { makeAtUri, parseAtUri } from "../../lib/util"; 11 + import { is } from "@atcute/lexicons/validations"; 12 + import { mainSchema as threadSchema } from "../../lexicons/types/xyz/atboards/thread"; 13 + import type { XyzAtboardsThread } 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(threadUri: 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(threadUri, `${REPLY}:subject`, 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, THREAD, tid); 46 + const [bbs, threadRecord, author, allRefs] = await Promise.all([ 47 + resolveBBS(handle), 48 + getRecord(did, THREAD, tid), 49 + resolveIdentity(did), 50 + collectAllReplyRefs(threadUri), 51 + ]); 52 + if (!is(threadSchema, threadRecord.value)) { 53 + throw new Response("Invalid thread record", { status: 404 }); 54 + } 55 + const threadValue = threadRecord.value as unknown as XyzAtboardsThread.Main; 56 + const boardSlug = parseAtUri(threadValue.board).rkey; 57 + const thread: ThreadObj = { 58 + uri: threadRecord.uri, 59 + did, 60 + rkey: tid, 61 + authorHandle: author.handle, 62 + authorPds: author.pds ?? "", 63 + title: threadValue.title, 64 + body: threadValue.body, 65 + createdAt: threadValue.createdAt, 66 + boardSlug, 67 + attachments: threadValue.attachments as ThreadObj["attachments"], 68 + }; 69 + 70 + return { handle, bbs, thread, allRefs }; 71 + }