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/Board: redesign and add poster avis

+179 -30
+81 -8
web/src/components/nav/ThreadLink.tsx
··· 1 1 import { Link } from "react-router-dom"; 2 + import Avatar from "../Avatar"; 3 + import type { Participant } from "../../router/loaders/board"; 4 + 5 + const COL_POSTERS = "w-20"; 6 + const COL_REPLIES = "w-14"; 7 + const COL_ACTIVITY = "w-16"; 8 + 9 + const MAX_AVATARS = 3; 10 + 11 + export function ThreadListHeader() { 12 + return ( 13 + <div className="hidden sm:flex items-center gap-4 px-3 -mx-3 pb-2 border-b border-neutral-800 text-xs text-neutral-500"> 14 + <span className="flex-1 min-w-0">Topic</span> 15 + <span className={`shrink-0 ${COL_POSTERS} text-center`}>Posters</span> 16 + <span className={`shrink-0 ${COL_REPLIES} text-center`}>Replies</span> 17 + <span className={`shrink-0 ${COL_ACTIVITY} text-center`}>Activity</span> 18 + </div> 19 + ); 20 + } 2 21 3 22 interface ThreadLinkProps { 4 23 to: string; 5 24 title: string; 6 - meta: string; 7 - preview: string; 25 + preview?: string; 26 + authorHandle: string; 27 + participants: Participant[]; 28 + replyCount: number; 29 + activity: string; 8 30 } 9 31 10 32 export default function ThreadLink({ 11 33 to, 12 34 title, 13 - meta, 14 35 preview, 36 + authorHandle, 37 + participants, 38 + replyCount, 39 + activity, 15 40 }: ThreadLinkProps) { 41 + const shownPosters = participants.slice(0, MAX_AVATARS); 42 + const hiddenPosterCount = participants.length - shownPosters.length; 16 43 return ( 17 44 <Link 18 45 to={to} 19 - className="block px-3 py-4 -mx-3 rounded hover:bg-neutral-800 group" 46 + className="block px-3 py-3 -mx-3 rounded hover:bg-neutral-800 group" 20 47 > 21 - <div className="flex items-baseline justify-between gap-4"> 22 - <span className="text-neutral-300 truncate">{title}</span> 23 - <span className="shrink-0 text-xs text-neutral-400">{meta}</span> 48 + <div className="sm:hidden"> 49 + <div className="flex items-baseline justify-between gap-4"> 50 + <span className="text-neutral-300 truncate">{title}</span> 51 + <span className="shrink-0 text-xs text-neutral-400"> 52 + {authorHandle} · {activity} 53 + </span> 54 + </div> 55 + {preview && ( 56 + <p className="text-neutral-400 text-xs mt-1 line-clamp-1"> 57 + {preview} 58 + </p> 59 + )} 24 60 </div> 25 - <p className="text-neutral-400 text-xs mt-1 line-clamp-1">{preview}</p> 61 + 62 + <div className="hidden sm:flex items-start gap-4"> 63 + <div className="flex-1 min-w-0"> 64 + <div className="text-neutral-300 truncate">{title}</div> 65 + {preview && ( 66 + <div className="text-xs text-neutral-400 truncate mt-1"> 67 + {preview} 68 + </div> 69 + )} 70 + </div> 71 + <div 72 + className={`shrink-0 flex items-center justify-center -space-x-2 ${COL_POSTERS} pt-0.5`} 73 + > 74 + {shownPosters.map((poster) => ( 75 + <div 76 + key={poster.did} 77 + className="rounded-full ring-2 ring-neutral-900 group-hover:ring-neutral-800" 78 + > 79 + <Avatar url={poster.avatar} name={poster.handle} size={20} /> 80 + </div> 81 + ))} 82 + {hiddenPosterCount > 0 && ( 83 + <span className="text-xs text-neutral-400 pl-3 tabular-nums"> 84 + +{hiddenPosterCount} 85 + </span> 86 + )} 87 + </div> 88 + <span 89 + className={`shrink-0 ${COL_REPLIES} text-center text-xs text-neutral-400 tabular-nums pt-1`} 90 + > 91 + {replyCount || null} 92 + </span> 93 + <span 94 + className={`shrink-0 ${COL_ACTIVITY} text-center text-xs text-neutral-400 pt-1`} 95 + > 96 + {activity} 97 + </span> 98 + </div> 26 99 </Link> 27 100 ); 28 101 }
+32
web/src/lib/atproto.ts
··· 45 45 const identityCache = new TTLCache<string, MiniDoc>(5 * 60 * 1000); 46 46 // `null` means we've looked and there's no avatar — cache that too so we don't refetch. 47 47 const avatarCache = new TTLCache<string, string | null>(5 * 60 * 1000); 48 + const backlinkCountCache = new TTLCache<string, number>(60 * 1000); 48 49 49 50 const BSKY_CDN = "https://cdn.bsky.app"; 50 51 const BSKY_PROFILE = "app.bsky.actor.profile"; ··· 156 157 let url = `${CONSTELLATION}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(subject)}&source=${encodeURIComponent(source)}&limit=${limit}`; 157 158 if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; 158 159 return fetchJson<BacklinksResponse>(url); 160 + } 161 + 162 + export async function getBacklinkCount( 163 + subject: string, 164 + source: string, 165 + ): Promise<number> { 166 + const key = `${source}\t${subject}`; 167 + const cached = backlinkCountCache.get(key); 168 + if (cached !== undefined) return cached; 169 + try { 170 + const { total } = await getBacklinks(subject, source, 1); 171 + backlinkCountCache.set(key, total); 172 + return total; 173 + } catch { 174 + return 0; 175 + } 176 + } 177 + 178 + export async function getBacklinkCountsBatch( 179 + subjects: string[], 180 + source: string, 181 + ): Promise<Record<string, number>> { 182 + const unique = [...new Set(subjects)]; 183 + const counts = await Promise.all( 184 + unique.map((subject) => getBacklinkCount(subject, source)), 185 + ); 186 + const map: Record<string, number> = {}; 187 + unique.forEach((subject, index) => { 188 + map[subject] = counts[index]; 189 + }); 190 + return map; 159 191 } 160 192 161 193 interface HydratedRecord {
+16 -10
web/src/pages/Board.tsx
··· 13 13 import { BOARD } from "../lib/lexicon"; 14 14 import { createPost, uploadAttachments } from "../lib/writes"; 15 15 import * as limits from "../lib/limits"; 16 - import ThreadLink from "../components/nav/ThreadLink"; 16 + import ThreadLink, { ThreadListHeader } from "../components/nav/ThreadLink"; 17 17 import ComposeForm from "../components/form/ComposeForm"; 18 18 import { 19 19 hydrateThreadPage, ··· 130 130 131 131 <div> 132 132 {threads.length ? ( 133 - threads.map((t) => ( 134 - <ThreadLink 135 - key={t.uri} 136 - to={`/bbs/${handle}/thread/${t.did}/${t.rkey}`} 137 - title={t.title} 138 - meta={`${t.handle} · ${relativeDate(t.lastActivityAt)}`} 139 - preview={t.body.substring(0, 120)} 140 - /> 141 - )) 133 + <> 134 + <ThreadListHeader /> 135 + {threads.map((t) => ( 136 + <ThreadLink 137 + key={t.uri} 138 + to={`/bbs/${handle}/thread/${t.did}/${t.rkey}`} 139 + title={t.title} 140 + preview={t.body.substring(0, 120)} 141 + authorHandle={t.handle} 142 + participants={t.participants} 143 + replyCount={t.replyCount} 144 + activity={relativeDate(t.lastActivityAt)} 145 + /> 146 + ))} 147 + </> 142 148 ) : ( 143 149 <p className="text-neutral-400">No threads yet.</p> 144 150 )}
+50 -12
web/src/router/loaders/board.ts
··· 1 1 import type { LoaderFunctionArgs } from "react-router-dom"; 2 2 import { resolveBBS, type BBS } from "../../lib/bbs"; 3 3 import { 4 + getAvatars, 5 + getBacklinkCountsBatch, 4 6 getBacklinks, 5 7 getRecordsBatch, 6 8 getRecordsByUri, ··· 11 13 import { is } from "@atcute/lexicons/validations"; 12 14 import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; 13 15 import type { XyzAtbbsPost } from "../../lexicons"; 16 + 17 + export interface Participant { 18 + did: string; 19 + handle: string; 20 + avatar?: string; 21 + } 14 22 15 23 export interface ThreadItem { 16 24 uri: string; ··· 21 29 body: string; 22 30 createdAt: string; 23 31 lastActivityAt: string; 32 + replyCount: number; 33 + participants: Participant[]; 24 34 } 25 35 26 36 const MAX_SCANS = 4; ··· 41 51 ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { 42 52 const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); 43 53 44 - // Phase 1: Scan board activity to find unique thread URIs. 45 - // Keys are thread URIs, values are the timestamp of their last activity. 54 + // Phase 1: Scan board activity to find unique thread URIs and their posters. 55 + // Constellation returns newest-first, so first-seen activity per thread = most 56 + // recent, and Set insertion order preserves newest-first poster order. 46 57 const lastActivity = new Map<string, string>(); 58 + const postersByThread = new Map<string, Set<string>>(); 47 59 let scanCursor = cursor; 48 60 49 - for (let scanCount = 0; scanCount < MAX_SCANS; scanCount++) { 61 + for (let scan = 0; scan < MAX_SCANS; scan++) { 50 62 if (lastActivity.size >= PAGE_SIZE) break; 51 63 52 64 const backlinks = await getBacklinks( ··· 65 77 if (!lastActivity.has(threadUri)) { 66 78 lastActivity.set(threadUri, value.createdAt); 67 79 } 80 + let posters = postersByThread.get(threadUri); 81 + if (!posters) { 82 + posters = new Set(); 83 + postersByThread.set(threadUri, posters); 84 + } 85 + posters.add(parseAtUri(record.uri).did); 68 86 } 69 87 70 88 scanCursor = backlinks.cursor; ··· 81 99 return value.title && !value.root; 82 100 }); 83 101 84 - // Phase 3: Resolve authors and build ThreadItems. 85 - const authors = await resolveIdentitiesBatch( 86 - validRoots.map((record) => parseAtUri(record.uri).did), 87 - ); 102 + // Phase 3: Resolve identities+avatars for every poster across all threads, 103 + // count replies, and build ThreadItems. 104 + const allDids = new Set<string>(); 105 + for (const record of validRoots) { 106 + allDids.add(parseAtUri(record.uri).did); 107 + const posters = postersByThread.get(record.uri); 108 + if (posters) for (const did of posters) allDids.add(did); 109 + } 110 + 111 + const [identities, replyCounts, avatars] = await Promise.all([ 112 + resolveIdentitiesBatch([...allDids]), 113 + getBacklinkCountsBatch( 114 + validRoots.map((record) => record.uri), 115 + `${POST}:root`, 116 + ), 117 + getAvatars([...allDids]), 118 + ]); 88 119 89 120 const threads: ThreadItem[] = validRoots 90 - .filter((record) => { 91 - const authorDid = parseAtUri(record.uri).did; 92 - return authorDid in authors; 93 - }) 121 + .filter((record) => parseAtUri(record.uri).did in identities) 94 122 .map((record) => { 95 123 const { did, rkey } = parseAtUri(record.uri); 96 124 const value = record.value as unknown as XyzAtbbsPost.Main; 125 + const posterDids = postersByThread.get(record.uri) ?? new Set([did]); 126 + const participants: Participant[] = [...posterDids] 127 + .filter((posterDid) => posterDid in identities) 128 + .map((posterDid) => ({ 129 + did: posterDid, 130 + handle: identities[posterDid].handle, 131 + avatar: avatars[posterDid], 132 + })); 97 133 return { 98 134 uri: record.uri, 99 135 did, 100 136 rkey, 101 - handle: authors[did].handle, 137 + handle: identities[did].handle, 102 138 title: value.title ?? "", 103 139 body: value.body, 104 140 createdAt: value.createdAt, 105 141 lastActivityAt: lastActivity.get(record.uri) ?? value.createdAt, 142 + replyCount: replyCounts[record.uri] ?? 0, 143 + participants, 106 144 }; 107 145 }) 108 146 .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));