From 5f0cd89e2c427416f667d746f8db7d4d08314c74 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Wed, 22 Apr 2026 23:05:20 -0400 Subject: [PATCH] web/Board: redesign and add poster avis --- web/src/components/nav/ThreadLink.tsx | 89 ++++++++++++++++++++++++--- web/src/lib/atproto.ts | 32 ++++++++++ web/src/pages/Board.tsx | 26 +++++--- web/src/router/loaders/board.ts | 62 +++++++++++++++---- 4 files changed, 179 insertions(+), 30 deletions(-) diff --git a/web/src/components/nav/ThreadLink.tsx b/web/src/components/nav/ThreadLink.tsx index c201671..5e562d4 100644 --- a/web/src/components/nav/ThreadLink.tsx +++ b/web/src/components/nav/ThreadLink.tsx @@ -1,28 +1,101 @@ import { Link } from "react-router-dom"; +import Avatar from "../Avatar"; +import type { Participant } from "../../router/loaders/board"; + +const COL_POSTERS = "w-20"; +const COL_REPLIES = "w-14"; +const COL_ACTIVITY = "w-16"; + +const MAX_AVATARS = 3; + +export function ThreadListHeader() { + return ( +
+ Topic + Posters + Replies + Activity +
+ ); +} interface ThreadLinkProps { to: string; title: string; - meta: string; - preview: string; + preview?: string; + authorHandle: string; + participants: Participant[]; + replyCount: number; + activity: string; } export default function ThreadLink({ to, title, - meta, preview, + authorHandle, + participants, + replyCount, + activity, }: ThreadLinkProps) { + const shownPosters = participants.slice(0, MAX_AVATARS); + const hiddenPosterCount = participants.length - shownPosters.length; return ( -
- {title} - {meta} +
+
+ {title} + + {authorHandle} · {activity} + +
+ {preview && ( +

+ {preview} +

+ )} +
+ +
+
+
{title}
+ {preview && ( +
+ {preview} +
+ )} +
+
+ {shownPosters.map((poster) => ( +
+ +
+ ))} + {hiddenPosterCount > 0 && ( + + +{hiddenPosterCount} + + )} +
+ + {replyCount || null} + + + {activity} +
-

{preview}

); } diff --git a/web/src/lib/atproto.ts b/web/src/lib/atproto.ts index d4673f5..766928c 100644 --- a/web/src/lib/atproto.ts +++ b/web/src/lib/atproto.ts @@ -45,6 +45,7 @@ async function fetchJson(url: string): Promise { const identityCache = new TTLCache(5 * 60 * 1000); // `null` means we've looked and there's no avatar — cache that too so we don't refetch. const avatarCache = new TTLCache(5 * 60 * 1000); +const backlinkCountCache = new TTLCache(60 * 1000); const BSKY_CDN = "https://cdn.bsky.app"; const BSKY_PROFILE = "app.bsky.actor.profile"; @@ -158,6 +159,37 @@ export async function getBacklinks( return fetchJson(url); } +export async function getBacklinkCount( + subject: string, + source: string, +): Promise { + const key = `${source}\t${subject}`; + const cached = backlinkCountCache.get(key); + if (cached !== undefined) return cached; + try { + const { total } = await getBacklinks(subject, source, 1); + backlinkCountCache.set(key, total); + return total; + } catch { + return 0; + } +} + +export async function getBacklinkCountsBatch( + subjects: string[], + source: string, +): Promise> { + const unique = [...new Set(subjects)]; + const counts = await Promise.all( + unique.map((subject) => getBacklinkCount(subject, source)), + ); + const map: Record = {}; + unique.forEach((subject, index) => { + map[subject] = counts[index]; + }); + return map; +} + interface HydratedRecord { uri: string; did: string; diff --git a/web/src/pages/Board.tsx b/web/src/pages/Board.tsx index 5c289de..5147ffe 100644 --- a/web/src/pages/Board.tsx +++ b/web/src/pages/Board.tsx @@ -13,7 +13,7 @@ import { makeAtUri, parseAtUri, relativeDate } from "../lib/util"; import { BOARD } from "../lib/lexicon"; import { createPost, uploadAttachments } from "../lib/writes"; import * as limits from "../lib/limits"; -import ThreadLink from "../components/nav/ThreadLink"; +import ThreadLink, { ThreadListHeader } from "../components/nav/ThreadLink"; import ComposeForm from "../components/form/ComposeForm"; import { hydrateThreadPage, @@ -130,15 +130,21 @@ export default function BoardPage() {
{threads.length ? ( - threads.map((t) => ( - - )) + <> + + {threads.map((t) => ( + + ))} + ) : (

No threads yet.

)} diff --git a/web/src/router/loaders/board.ts b/web/src/router/loaders/board.ts index a92b532..cc61d42 100644 --- a/web/src/router/loaders/board.ts +++ b/web/src/router/loaders/board.ts @@ -1,6 +1,8 @@ import type { LoaderFunctionArgs } from "react-router-dom"; import { resolveBBS, type BBS } from "../../lib/bbs"; import { + getAvatars, + getBacklinkCountsBatch, getBacklinks, getRecordsBatch, getRecordsByUri, @@ -12,6 +14,12 @@ import { is } from "@atcute/lexicons/validations"; import { mainSchema as postSchema } from "../../lexicons/types/xyz/atbbs/post"; import type { XyzAtbbsPost } from "../../lexicons"; +export interface Participant { + did: string; + handle: string; + avatar?: string; +} + export interface ThreadItem { uri: string; did: string; @@ -21,6 +29,8 @@ export interface ThreadItem { body: string; createdAt: string; lastActivityAt: string; + replyCount: number; + participants: Participant[]; } const MAX_SCANS = 4; @@ -41,12 +51,14 @@ export async function hydrateThreadPage( ): Promise<{ threads: ThreadItem[]; cursor: string | null }> { const boardUri = makeAtUri(bbs.identity.did, BOARD, slug); - // Phase 1: Scan board activity to find unique thread URIs. - // Keys are thread URIs, values are the timestamp of their last activity. + // Phase 1: Scan board activity to find unique thread URIs and their posters. + // Constellation returns newest-first, so first-seen activity per thread = most + // recent, and Set insertion order preserves newest-first poster order. const lastActivity = new Map(); + const postersByThread = new Map>(); let scanCursor = cursor; - for (let scanCount = 0; scanCount < MAX_SCANS; scanCount++) { + for (let scan = 0; scan < MAX_SCANS; scan++) { if (lastActivity.size >= PAGE_SIZE) break; const backlinks = await getBacklinks( @@ -65,6 +77,12 @@ export async function hydrateThreadPage( if (!lastActivity.has(threadUri)) { lastActivity.set(threadUri, value.createdAt); } + let posters = postersByThread.get(threadUri); + if (!posters) { + posters = new Set(); + postersByThread.set(threadUri, posters); + } + posters.add(parseAtUri(record.uri).did); } scanCursor = backlinks.cursor; @@ -81,28 +99,48 @@ export async function hydrateThreadPage( return value.title && !value.root; }); - // Phase 3: Resolve authors and build ThreadItems. - const authors = await resolveIdentitiesBatch( - validRoots.map((record) => parseAtUri(record.uri).did), - ); + // Phase 3: Resolve identities+avatars for every poster across all threads, + // count replies, and build ThreadItems. + const allDids = new Set(); + for (const record of validRoots) { + allDids.add(parseAtUri(record.uri).did); + const posters = postersByThread.get(record.uri); + if (posters) for (const did of posters) allDids.add(did); + } + + const [identities, replyCounts, avatars] = await Promise.all([ + resolveIdentitiesBatch([...allDids]), + getBacklinkCountsBatch( + validRoots.map((record) => record.uri), + `${POST}:root`, + ), + getAvatars([...allDids]), + ]); const threads: ThreadItem[] = validRoots - .filter((record) => { - const authorDid = parseAtUri(record.uri).did; - return authorDid in authors; - }) + .filter((record) => parseAtUri(record.uri).did in identities) .map((record) => { const { did, rkey } = parseAtUri(record.uri); const value = record.value as unknown as XyzAtbbsPost.Main; + const posterDids = postersByThread.get(record.uri) ?? new Set([did]); + const participants: Participant[] = [...posterDids] + .filter((posterDid) => posterDid in identities) + .map((posterDid) => ({ + did: posterDid, + handle: identities[posterDid].handle, + avatar: avatars[posterDid], + })); return { uri: record.uri, did, rkey, - handle: authors[did].handle, + handle: identities[did].handle, title: value.title ?? "", body: value.body, createdAt: value.createdAt, lastActivityAt: lastActivity.get(record.uri) ?? value.createdAt, + replyCount: replyCounts[record.uri] ?? 0, + participants, }; }) .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt)); -- 2.51.2