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: standardiize url building

+66 -35
+2 -1
web/src/components/dashboard/ActivityList.tsx
··· 2 2 import { ChevronDown } from "lucide-react"; 3 3 import { Link } from "react-router-dom"; 4 4 import { parseAtUri } from "../../lib/util"; 5 + import { threadUrl } from "../../lib/routes"; 5 6 import PostBody from "../post/PostBody"; 6 7 import PostMeta from "../post/PostMeta"; 7 8 import type { ActivityItem } from "../../lib/activity"; ··· 24 25 {items.slice(0, shown).map((item) => { 25 26 const { did: threadDid, rkey: threadRkey } = parseAtUri(item.threadUri); 26 27 const { rkey: replyRkey } = parseAtUri(item.replyUri); 27 - const url = `/bbs/${userHandle}/thread/${threadDid}/${threadRkey}#reply-${replyRkey}`; 28 + const url = `${threadUrl(userHandle, threadDid, threadRkey)}#reply-${replyRkey}`; 28 29 return ( 29 30 <Link 30 31 key={item.replyUri}
+2 -1
web/src/components/dashboard/BBSPanel.tsx
··· 4 4 import { ArrowRight, Pencil, Plus, Shield, Trash2 } from "lucide-react"; 5 5 import { ActionLink } from "../nav/ActionButton"; 6 6 import { getAvatar } from "../../lib/atproto"; 7 + import { bbsUrl } from "../../lib/routes"; 7 8 8 9 interface BBSPanelProps { 9 10 hasBBS: boolean; ··· 41 42 return ( 42 43 <> 43 44 <Link 44 - to={`/bbs/${encodeURIComponent(userHandle)}`} 45 + to={bbsUrl(userHandle)} 45 46 className="flex items-center justify-between py-3 -mx-3 px-3 rounded hover:bg-neutral-800 mb-3" 46 47 > 47 48 <div className="flex items-center gap-3 min-w-0 text-neutral-200">
+4 -3
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 { bbsUrl } from "../../lib/routes"; 9 10 import type { DiscoveredBBS } from "../../lib/discovery"; 10 11 11 12 export interface Suggestion { ··· 21 22 avatar?: string; 22 23 }): Suggestion { 23 24 return { 24 - to: `/bbs/${encodeURIComponent(bbs.handle)}`, 25 + to: bbsUrl(bbs.handle), 25 26 name: bbs.name, 26 27 handle: bbs.handle, 27 28 avatar: bbs.avatar, ··· 73 74 function onSubmit(event: SyntheticEvent) { 74 75 event.preventDefault(); 75 76 const trimmed = inputValue.trim(); 76 - if (trimmed) navigate(`/bbs/${encodeURIComponent(trimmed)}`); 77 + if (trimmed) navigate(bbsUrl(trimmed)); 77 78 } 78 79 79 80 function onRandom() { 80 81 if (discovered?.length) { 81 82 const pick = discovered[Math.floor(Math.random() * discovered.length)]; 82 - navigate(`/bbs/${encodeURIComponent(pick.handle)}`); 83 + navigate(bbsUrl(pick.handle)); 83 84 } else if (suggestions?.length) { 84 85 const pick = suggestions[Math.floor(Math.random() * suggestions.length)]; 85 86 navigate(pick.to);
+2 -1
web/src/components/dashboard/DiscoveryList.tsx
··· 1 1 import ListLink from "../nav/ListLink"; 2 + import { bbsUrl } from "../../lib/routes"; 2 3 import type { DiscoveredBBS } from "../../lib/discovery"; 3 4 4 5 interface DiscoveryListProps { ··· 21 22 {discovered.slice(0, limit).map((bbs) => ( 22 23 <ListLink 23 24 key={bbs.handle} 24 - to={`/bbs/${encodeURIComponent(bbs.handle)}`} 25 + to={bbsUrl(bbs.handle)} 25 26 name={bbs.name} 26 27 description={bbs.handle} 27 28 />
+2 -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 { threadUrl } from "../../lib/routes"; 5 6 import type { MyThread } from "../../lib/mythreads"; 6 7 7 8 const PAGE_SIZE = 10; ··· 20 21 <div> 21 22 {threads.slice(0, shown).map((thread) => { 22 23 const { did, rkey } = parseAtUri(thread.uri); 23 - const url = `/bbs/${thread.bbsHandle}/thread/${did}/${rkey}`; 24 + const url = threadUrl(thread.bbsHandle, did, rkey); 24 25 return ( 25 26 <Link 26 27 key={thread.uri}
+2 -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 { bbsUrl } from "../../lib/routes"; 4 5 import type { PinnedBBS } from "../../lib/pins"; 5 6 6 7 const PAGE_SIZE = 5; ··· 20 21 {pins.slice(0, shown).map((entry) => ( 21 22 <ListLink 22 23 key={entry.did} 23 - to={`/bbs/${entry.handle}`} 24 + to={bbsUrl(entry.handle)} 24 25 name={entry.name} 25 26 description={entry.handle} 26 27 avatar={entry.avatar}
+2 -1
web/src/components/layout/Header.tsx
··· 1 1 import { Link, useNavigate } from "react-router-dom"; 2 2 import { useAuth } from "../../lib/auth"; 3 3 import { useLoginModal } from "../../lib/loginModal"; 4 + import { profileUrl } from "../../lib/routes"; 4 5 import Logo from "./Logo"; 5 6 import HeaderBreadcrumbs from "./HeaderBreadcrumbs"; 6 7 import MobileMenu from "./MobileMenu"; ··· 31 32 {user ? ( 32 33 <> 33 34 <Link 34 - to={`/profile/${encodeURIComponent(user.handle)}`} 35 + to={profileUrl(user.handle)} 35 36 className={linkStyle} 36 37 > 37 38 {user.handle}
+2 -1
web/src/components/layout/MobileMenu.tsx
··· 2 2 import { useState } from "react"; 3 3 import type { useAuth } from "../../lib/auth"; 4 4 import { useLoginModal } from "../../lib/loginModal"; 5 + import { profileUrl } from "../../lib/routes"; 5 6 6 7 interface MobileMenuProps { 7 8 user: ReturnType<typeof useAuth>["user"]; ··· 40 41 {user ? ( 41 42 <> 42 43 <Link 43 - to={`/profile/${encodeURIComponent(user.handle)}`} 44 + to={profileUrl(user.handle)} 44 45 onClick={close} 45 46 className="text-neutral-300 hover:text-neutral-200" 46 47 >
+2 -1
web/src/components/post/PostMeta.tsx
··· 1 1 import { useNavigate } from "react-router-dom"; 2 2 import { formatFullDate, relativeDate } from "../../lib/util"; 3 + import { profileUrl } from "../../lib/routes"; 3 4 4 5 interface PostMetaProps { 5 6 handle: string; ··· 12 13 function handleClick(event: React.MouseEvent) { 13 14 event.preventDefault(); 14 15 event.stopPropagation(); 15 - navigate(`/profile/${encodeURIComponent(handle)}`); 16 + navigate(profileUrl(handle)); 16 17 } 17 18 18 19 return (
+2 -1
web/src/components/profile/ViewProfile.tsx
··· 3 3 import Avatar from "../Avatar"; 4 4 import PostBody from "../post/PostBody"; 5 5 import { ActionButton } from "../nav/ActionButton"; 6 + import { bbsUrl } from "../../lib/routes"; 6 7 import type { Profile } from "../../lib/profile"; 7 8 8 9 interface ViewProfileProps { ··· 59 60 <Monitor size={12} /> Community 60 61 </p> 61 62 <Link 62 - to={`/bbs/${handle}`} 63 + to={bbsUrl(handle)} 63 64 className="flex items-center justify-between bg-neutral-900 border border-neutral-800 rounded px-4 py-3 hover:border-neutral-700 group" 64 65 > 65 66 <div>
+2 -1
web/src/hooks/useResolvedBBS.ts
··· 3 3 import { useEffect, useState } from "react"; 4 4 import { resolveIdentity, getRecord, getAvatar } from "../lib/atproto"; 5 5 import { SITE } from "../lib/lexicon"; 6 + import { bbsUrl } from "../lib/routes"; 6 7 import type { Suggestion } from "../components/dashboard/DialBBS"; 7 8 8 9 const DEBOUNCE_MS = 300; ··· 28 29 const siteValue = siteRecord.value as { name?: string }; 29 30 if (!cancelled) { 30 31 setResult({ 31 - to: `/bbs/${encodeURIComponent(identity.handle)}`, 32 + to: bbsUrl(identity.handle), 32 33 name: siteValue.name ?? identity.handle, 33 34 handle: identity.handle, 34 35 avatar,
+18
web/src/lib/routes.ts
··· 1 + // Typed path builders for every internal URL. Centralizes encoding so handle 2 + // and slug (user-authored) always round-trip safely through the router. 3 + // DID and rkey are AT Proto formats with URL-safe character sets. 4 + 5 + export const bbsUrl = (handle: string) => 6 + `/bbs/${encodeURIComponent(handle)}`; 7 + 8 + export const boardUrl = (handle: string, slug: string) => 9 + `/bbs/${encodeURIComponent(handle)}/board/${encodeURIComponent(slug)}`; 10 + 11 + export const threadUrl = (handle: string, did: string, rkey: string) => 12 + `/bbs/${encodeURIComponent(handle)}/thread/${did}/${rkey}`; 13 + 14 + export const newsUrl = (handle: string, rkey: string) => 15 + `/bbs/${encodeURIComponent(handle)}/news/${rkey}`; 16 + 17 + export const profileUrl = (handle: string) => 18 + `/profile/${encodeURIComponent(handle)}`;
+5 -7
web/src/pages/BBS.tsx
··· 19 19 import { makeAtUri, nowIso, parseAtUri, truncate } from "../lib/util"; 20 20 import * as limits from "../lib/limits"; 21 21 import { bbsQuery, newsQuery, pinsQuery } from "../lib/queries"; 22 + import { bbsUrl, boardUrl, newsUrl, profileUrl } from "../lib/routes"; 22 23 import { queryClient } from "../lib/queryClient"; 23 24 import { alertOnError } from "../lib/alerts"; 24 25 import type { NewsPost } from "../lib/bbs"; ··· 48 49 const pinRkey = user && pins ? findPinRkey(pins, bbs.identity.did) : null; 49 50 50 51 useBreadcrumb( 51 - [{ label: bbs.site.name, to: `/bbs/${handle}` }], 52 + [{ label: bbs.site.name, to: bbsUrl(handle!) }], 52 53 [bbs, handle], 53 54 ); 54 55 usePageTitle(`${bbs.site.name} — atbbs`); ··· 116 117 bbsDid={bbs.identity.did} 117 118 initialRkey={pinRkey} 118 119 /> 119 - <ActionLink 120 - to={`/profile/${encodeURIComponent(handle!)}`} 121 - icon={UserCog} 122 - > 120 + <ActionLink to={profileUrl(handle!)} icon={UserCog}> 123 121 admin 124 122 </ActionLink> 125 123 {isSysop && ( ··· 149 147 {bbs.site.boards.map((board) => ( 150 148 <ListLink 151 149 key={board.slug} 152 - to={`/bbs/${handle}/board/${board.slug}`} 150 + to={boardUrl(handle!, board.slug)} 153 151 name={board.name} 154 152 description={board.description} 155 153 /> ··· 192 190 {visibleNews.map((item, i) => ( 193 191 <Link 194 192 key={item.rkey} 195 - to={`/bbs/${handle}/news/${item.rkey}`} 193 + to={newsUrl(handle!, item.rkey)} 196 194 className={`reply-card block bg-neutral-900 border border-neutral-800 rounded p-4 hover:border-neutral-700 ${i < visibleNews.length - 1 ? "mb-2" : ""}`} 197 195 > 198 196 <div className="flex items-baseline gap-2 mb-2">
+5 -4
web/src/pages/Board.tsx
··· 22 22 myThreadsQuery, 23 23 } from "../lib/queries"; 24 24 import { queryClient } from "../lib/queryClient"; 25 + import { bbsUrl, boardUrl, threadUrl } from "../lib/routes"; 25 26 import { alertOnError } from "../lib/alerts"; 26 27 import type { ThreadItem, ThreadPageResult } from "../lib/boardThreads"; 27 28 import ThreadLink, { ThreadListHeader } from "../components/nav/ThreadLink"; ··· 86 87 usePageTitle(`${board.name} — ${bbs.site.name}`); 87 88 useBreadcrumb( 88 89 [ 89 - { label: bbs.site.name, to: `/bbs/${handle}` }, 90 - { label: board.name, to: `/bbs/${handle}/board/${board.slug}` }, 90 + { label: bbs.site.name, to: bbsUrl(handle!) }, 91 + { label: board.name, to: boardUrl(handle!, board.slug) }, 91 92 ], 92 93 [bbs, board, handle], 93 94 ); ··· 149 150 setTitle(""); 150 151 setBody(""); 151 152 setFiles([]); 152 - navigate(`/bbs/${handle}/thread/${did}/${rkey}`); 153 + navigate(threadUrl(handle!, did, rkey)); 153 154 }, 154 155 onError: alertOnError("post"), 155 156 }); ··· 200 201 {threads.map((t) => ( 201 202 <ThreadLink 202 203 key={t.uri} 203 - to={`/bbs/${handle}/thread/${t.did}/${t.rkey}`} 204 + to={threadUrl(handle!, t.did, t.rkey)} 204 205 title={t.title} 205 206 preview={t.body.substring(0, 120)} 206 207 authorHandle={t.handle}
+3 -2
web/src/pages/News.tsx
··· 6 6 import { POST } from "../lib/lexicon"; 7 7 import { deleteRecord } from "../lib/writes"; 8 8 import { bbsQuery, newsQuery } from "../lib/queries"; 9 + import { bbsUrl } from "../lib/routes"; 9 10 import { alertOnError } from "../lib/alerts"; 10 11 import NewsCard from "../components/post/NewsCard"; 11 12 ··· 20 21 21 22 useBreadcrumb( 22 23 [ 23 - { label: bbs.site.name, to: `/bbs/${handle}` }, 24 + { label: bbs.site.name, to: bbsUrl(handle!) }, 24 25 { label: item?.title ?? "News" }, 25 26 ], 26 27 [bbs, handle, tid], ··· 36 37 if (!agent || !tid) throw new Error("Not signed in"); 37 38 await deleteRecord(agent, POST, tid); 38 39 }, 39 - onSuccess: () => navigate(`/bbs/${handle}`), 40 + onSuccess: () => navigate(bbsUrl(handle!)), 40 41 onError: alertOnError("delete"), 41 42 }); 42 43
+2 -1
web/src/pages/SysopCreate.tsx
··· 7 7 import { makeAtUri, nowIso } from "../lib/util"; 8 8 import * as limits from "../lib/limits"; 9 9 import { usePageTitle } from "../hooks/usePageTitle"; 10 + import { bbsUrl } from "../lib/routes"; 10 11 import { Input, Textarea, Button } from "../components/form/Form"; 11 12 import BoardRowEditor, { 12 13 type BoardRow, ··· 64 65 ), 65 66 createdAt: now, 66 67 }); 67 - navigate(`/bbs/${user.handle}`); 68 + navigate(bbsUrl(user.handle)); 68 69 } catch { 69 70 setError("Could not create community."); 70 71 }
+3 -2
web/src/pages/SysopEdit.tsx
··· 9 9 import { useBreadcrumb } from "../hooks/useBreadcrumb"; 10 10 import { usePageTitle } from "../hooks/usePageTitle"; 11 11 import { bbsQuery } from "../lib/queries"; 12 + import { bbsUrl } from "../lib/routes"; 12 13 import { Input, Textarea, Button } from "../components/form/Form"; 13 14 import BoardRowEditor, { 14 15 type BoardRow, ··· 37 38 usePageTitle("Edit community — atbbs"); 38 39 useBreadcrumb( 39 40 [ 40 - { label: bbs.site.name, to: `/bbs/${user!.handle}` }, 41 + { label: bbs.site.name, to: bbsUrl(user!.handle) }, 41 42 { label: "Edit" }, 42 43 ], 43 44 [bbs, user!.handle], ··· 74 75 createdAt: bbs.site.createdAt || now, 75 76 updatedAt: now, 76 77 }); 77 - navigate(`/bbs/${user.handle}`); 78 + navigate(bbsUrl(user.handle)); 78 79 } catch { 79 80 setError("Could not update community."); 80 81 }
+2 -1
web/src/pages/SysopModerate.tsx
··· 2 2 import { useSuspenseQuery } from "@tanstack/react-query"; 3 3 import { useAuth } from "../lib/auth"; 4 4 import { bbsQuery, sysopModerationQuery } from "../lib/queries"; 5 + import { bbsUrl } from "../lib/routes"; 5 6 import HandleInput from "../components/form/HandleInput"; 6 7 import { Button } from "../components/form/Form"; 7 8 import { useBreadcrumb } from "../hooks/useBreadcrumb"; ··· 64 65 65 66 useBreadcrumb( 66 67 [ 67 - { label: bbs.site.name, to: `/bbs/${user!.handle}` }, 68 + { label: bbs.site.name, to: bbsUrl(user!.handle) }, 68 69 { label: "Moderate" }, 69 70 ], 70 71 [bbs, user!.handle],
+4 -5
web/src/pages/Thread.tsx
··· 21 21 threadRootQuery, 22 22 } from "../lib/queries"; 23 23 import { queryClient } from "../lib/queryClient"; 24 + import { bbsUrl, boardUrl } from "../lib/routes"; 24 25 import { threadUriFor } from "../lib/thread"; 25 26 import { REPLIES_PER_PAGE } from "../lib/replies"; 26 27 import { ··· 166 167 if (user) { 167 168 queryClient.invalidateQueries(myThreadsQuery(user.pdsUrl, user.did)); 168 169 } 169 - navigate(`/bbs/${handle}`); 170 + navigate(bbsUrl(handle!)); 170 171 }, 171 172 onError: alertOnError("delete"), 172 173 }); ··· 318 319 ) { 319 320 const board = bbs.site.boards.find((b) => b.slug === boardSlug); 320 321 return [ 321 - { label: bbs.site.name, to: `/bbs/${handle}` }, 322 - ...(board 323 - ? [{ label: board.name, to: `/bbs/${handle}/board/${board.slug}` }] 324 - : []), 322 + { label: bbs.site.name, to: bbsUrl(handle) }, 323 + ...(board ? [{ label: board.name, to: boardUrl(handle, board.slug) }] : []), 325 324 { label: threadTitle }, 326 325 ]; 327 326 }