diff --git a/web/src/components/DialBBS.tsx b/web/src/components/DialBBS.tsx --- a/web/src/components/DialBBS.tsx +++ b/web/src/components/DialBBS.tsx @@ -12,6 +12,20 @@ to: string; name: string; handle: string; + avatar?: string; +} + +export function bbsToSuggestion(bbs: { + handle: string; + name: string; + avatar?: string; +}): Suggestion { + return { + to: `/bbs/${encodeURIComponent(bbs.handle)}`, + name: bbs.name, + handle: bbs.handle, + avatar: bbs.avatar, + }; } interface DialBBSProps { @@ -121,6 +135,7 @@ }`} > {avatar && ( - + )}
{primary}
diff --git a/web/src/hooks/useDiscovery.ts b/web/src/hooks/useDiscovery.ts --- a/web/src/hooks/useDiscovery.ts +++ b/web/src/hooks/useDiscovery.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { TTLCache } from "../lib/cache"; -import { getRecord, resolveIdentitiesBatch } from "../lib/atproto"; +import { getAvatars, getRecord, resolveIdentitiesBatch } from "../lib/atproto"; import { SITE } from "../lib/lexicon"; import { SERVICES } from "../lib/shared"; import { is } from "@atcute/lexicons/validations"; @@ -18,6 +18,7 @@ handle: string; name: string; description: string; + avatar?: string; } const discoveryCache = new TTLCache(5 * 60 * 1000); @@ -60,6 +61,11 @@ } catch { continue; } + } + + const avatars = await getAvatars(items.map((item) => item.did)); + for (const item of items) { + item.avatar = avatars[item.did]; } discoveryCache.set("all", items); diff --git a/web/src/hooks/useResolvedBBS.ts b/web/src/hooks/useResolvedBBS.ts --- a/web/src/hooks/useResolvedBBS.ts +++ b/web/src/hooks/useResolvedBBS.ts @@ -1,7 +1,7 @@ /** Debounced BBS resolution — resolves a handle to a BBS name if one exists. */ import { useEffect, useState } from "react"; -import { resolveIdentity, getRecord } from "../lib/atproto"; +import { resolveIdentity, getRecord, getAvatar } from "../lib/atproto"; import { SITE } from "../lib/lexicon"; import type { Suggestion } from "../components/DialBBS"; @@ -21,13 +21,17 @@ const timeout = setTimeout(async () => { try { const identity = await resolveIdentity(trimmed); - const siteRecord = await getRecord(identity.did, SITE, "self"); + const [siteRecord, avatar] = await Promise.all([ + getRecord(identity.did, SITE, "self"), + getAvatar(identity.did), + ]); const siteValue = siteRecord.value as { name?: string }; if (!cancelled) { setResult({ to: `/bbs/${encodeURIComponent(identity.handle)}`, name: siteValue.name ?? identity.handle, handle: identity.handle, + avatar, }); } } catch { diff --git a/web/src/lib/atproto.ts b/web/src/lib/atproto.ts --- a/web/src/lib/atproto.ts +++ b/web/src/lib/atproto.ts @@ -43,6 +43,44 @@ } 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 BSKY_CDN = "https://cdn.bsky.app"; +const BSKY_PROFILE = "app.bsky.actor.profile"; + +function extractAvatarCid(value: Record): string | null { + const avatar = value.avatar as { ref?: { $link?: string } } | undefined; + return avatar?.ref?.$link ?? null; +} + +export async function getAvatar(did: string): Promise { + const cached = avatarCache.get(did); + if (cached !== undefined) return cached ?? undefined; + try { + const record = await getRecord(did, BSKY_PROFILE, "self"); + const cid = extractAvatarCid(record.value); + const url = cid ? `${BSKY_CDN}/img/avatar/plain/${did}/${cid}` : null; + avatarCache.set(did, url); + return url ?? undefined; + } catch { + avatarCache.set(did, null); + return undefined; + } +} + +export async function getAvatars( + dids: string[], +): Promise> { + const unique = [...new Set(dids)]; + const urls = await Promise.all(unique.map(getAvatar)); + const map: Record = {}; + unique.forEach((did, index) => { + const url = urls[index]; + if (url) map[did] = url; + }); + return map; +} export async function resolveIdentity(identifier: string): Promise { const cached = identityCache.get(identifier); diff --git a/web/src/lib/pins.ts b/web/src/lib/pins.ts --- a/web/src/lib/pins.ts +++ b/web/src/lib/pins.ts @@ -1,6 +1,11 @@ /** Fetch and resolve the user's pinned BBSes. */ -import { listRecords, getRecord, resolveIdentitiesBatch } from "./atproto"; +import { + getAvatars, + getRecord, + listRecords, + resolveIdentitiesBatch, +} from "./atproto"; import { PIN, SITE } from "./lexicon"; import { is } from "@atcute/lexicons/validations"; import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; @@ -14,6 +19,7 @@ handle: string; name: string; createdAt: string; + avatar?: string; } export async function fetchPins( @@ -28,11 +34,14 @@ ); if (!pinnedDids.length) return []; - const identities = await resolveIdentitiesBatch(pinnedDids); + const [identities, siteResults, avatars] = await Promise.all([ + resolveIdentitiesBatch(pinnedDids), + Promise.allSettled( + pinnedDids.map((pinnedDid) => getRecord(pinnedDid, SITE, "self")), + ), + getAvatars(pinnedDids), + ]); - const siteResults = await Promise.allSettled( - pinnedDids.map((pinnedDid) => getRecord(pinnedDid, SITE, "self")), - ); const siteNames: Record = {}; siteResults.forEach((result, index) => { if (result.status !== "fulfilled") return; @@ -52,6 +61,7 @@ handle: identity.handle, name: siteNames[value.did] ?? identity.handle, createdAt: value.createdAt, + avatar: avatars[value.did], }); } results.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -4,7 +4,10 @@ import { deleteBBS } from "../lib/deletebbs"; import { useDiscovery } from "../hooks/useDiscovery"; import { usePageTitle } from "../hooks/usePageTitle"; -import DialBBS, { type Suggestion } from "../components/DialBBS"; +import DialBBS, { + bbsToSuggestion, + type Suggestion, +} from "../components/DialBBS"; import PinnedList from "../components/PinnedList"; import MyThreadList from "../components/MyThreadList"; import ActivityList from "../components/ActivityList"; @@ -47,19 +50,11 @@ const suggestions = useMemo(() => { const pinnedDids = new Set(pins.map((pin) => pin.did)); - const fromPins: Suggestion[] = pins.map((pin) => ({ - to: `/bbs/${pin.handle}`, - name: pin.name, - handle: pin.handle, - })); - const fromDiscovery: Suggestion[] = discoveredBBSes + const fromPins = pins.map(bbsToSuggestion); + const fromDiscovery = discoveredBBSes .filter((bbs) => !pinnedDids.has(bbs.did)) .slice(0, 5) - .map((bbs) => ({ - to: `/bbs/${encodeURIComponent(bbs.handle)}`, - name: bbs.name, - handle: bbs.handle, - })); + .map(bbsToSuggestion); return [...fromPins, ...fromDiscovery]; }, [pins, discoveredBBSes]); diff --git a/web/src/pages/LoggedOutHome.tsx b/web/src/pages/LoggedOutHome.tsx --- a/web/src/pages/LoggedOutHome.tsx +++ b/web/src/pages/LoggedOutHome.tsx @@ -2,18 +2,16 @@ import { Phone, Copy, Check } from "lucide-react"; import { useDiscovery } from "../hooks/useDiscovery"; import { usePageTitle } from "../hooks/usePageTitle"; -import DialBBS, { type Suggestion } from "../components/DialBBS"; +import DialBBS, { + bbsToSuggestion, + type Suggestion, +} from "../components/DialBBS"; import DiscoveryList from "../components/DiscoveryList"; export default function LoggedOutHome() { const discovered = useDiscovery(); const suggestions = useMemo( - () => - discovered.map((entry) => ({ - to: `/bbs/${entry.handle}`, - name: entry.name, - handle: entry.handle, - })), + () => discovered.map(bbsToSuggestion), [discovered], ); const [tab, setTab] = useState<"brew" | "uv" | "telnet">("brew");