Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
6.8 kB · 188 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189// Who else subscribes, and which of them you follow.//// Off the render path and allowed to fail: the row is drawn from whatever the// last lookup produced, and a lookup that produces nothing leaves the row as// it was. Constellation is a third party, so a publication with no answer must// look like a publication with no row, not like a publication with no// subscribers (see src/lib/subscribers.ts).
import { bskyProfileUrl } from '../../lib/atproto'import { peek, put } from '../../lib/cache'import { FOLLOWS_CAP, type FollowedSubscriber, SCAN_CAP, type SubscriberSummary, followedSubscribers, scanSubscribers,} from '../../lib/subscribers'import { $ } from '../dom'import type { Card, CardHost } from './card'import { resetFollows, viewerFollows } from './follows'import { placeholder } from './loading'
let subscribers: { uri: string; summary: SubscriberSummary } | undefinedlet loadedFor: string | undefined
export const subscribersCard: Card = { id: 'subscribers',
async load(host, refresh) { const pub = host.state()?.pub if (!pub) return if (!refresh && loadedFor === pub.uri) return loadedFor = pub.uri // Keyed by viewer as well as publication: the faces are the viewer's // follows, so one person's answer is not another's. const viewerDid = host.session()?.did const key = { scope: 'world', subject: pub.uri, name: `subscribers/${viewerDid ?? 'anon'}` } as const
const stored = refresh ? undefined : await peek<SubscriberSummary>(key) if (stored) { show(host, pub.uri, stored) return }
// Constellation first and alone. The count is in its first response and // needs no follow set, so the row is on screen before the walk that the // faces wait on has finished — or even, on a cold cache, started. const scan = await scanSubscribers(pub.uri, pub.handle, { faces: !!viewerDid }) if (!scan) return const counted: SubscriberSummary = { total: scan.total, followed: [], followedTotal: 0, truncated: false, } show(host, pub.uri, counted)
// Nobody to draw faces for: the count is the whole answer, so keep it. if (!viewerDid) { await put(key, counted) return } const viewer = await viewerFollows(host, refresh) // The walk failed, or the account changed under it. The count stands, and // stays uncached: a half-answer is not one to remember for a day. if (!viewer || viewer.did !== viewerDid) return
const summary: SubscriberSummary = { total: scan.total, ...(await followedSubscribers(scan, viewer)), truncated: scan.truncated, followsTruncated: viewer.truncated, } // Not `cached`: a summary that could not be built is a hidden row, and a // hidden row is not an answer worth remembering for a day. await put(key, summary) show(host, pub.uri, summary) },
render(host) { const row = $('subscribers') const pub = host.state()?.pub const summary = pub && subscribers?.uri === pub.uri ? subscribers.summary : undefined // A publication nobody subscribes to says nothing worth a row of its own. if (!summary || summary.total === 0) { // A count still on its way gets the row's shape; a count that came back // zero gets no row. Which means a slow lookup that ends in zero does // take the row back down — the only shift this file can cause, and the // delay in loading.ts keeps it off every fast answer. renderPlaceholder( row, !summary && !!pub && host.loading('subscribers'), // Faces are the viewer's own follows, so a signed-out row is the // count alone and its placeholder should be too. host.session() ? 3 : 0, ) return } placeholder(row, false) $('subscriber-faces').replaceChildren(...summary.followed.map(faceFor)) const parts: string[] = [] if (summary.followedTotal > 0) parts.push(`${summary.followedTotal} you follow`) parts.push(summary.total === 1 ? '1 subscriber' : `${summary.total} subscribers`) const count = $('subscriber-count') count.textContent = parts.join(' · ') // The count is exact whatever happened; only the faces come from a window, // and there are two windows they can be missing from. count.title = facesCaveat(summary) row.hidden = false },
reset() { // The faces were the old account's follows. subscribers = undefined loadedFor = undefined resetFollows() },}
/** The row's shape in grey: a few faces, and a bar where the count goes. */function renderPlaceholder(row: HTMLElement, on: boolean, faces: number) { row.hidden = !on placeholder(row, on) if (!on) return $('subscriber-faces').replaceChildren( ...Array.from({ length: faces }, () => { const face = document.createElement('span') face.className = 'face' return face }), ) const count = $('subscriber-count') count.textContent = '' count.title = ''}
/** * Why a face the reader expected might not be here. Either cap can do it: the * subscribers we looked at, and the follows we could recognise them from. */export function facesCaveat(summary: SubscriberSummary): string { const parts: string[] = [] if (summary.truncated) parts.push(`Faces are from the first ${SCAN_CAP} subscribers.`) if (summary.followsTruncated) { parts.push(`Matched against the first ${FOLLOWS_CAP.toLocaleString()} accounts you follow.`) } return parts.join(' ')}
/** Hold an answer and redraw. Called twice per load: the count, then the faces. */function show(host: CardHost, uri: string, summary: SubscriberSummary): void { subscribers = { uri, summary } host.rerender()}
/** One face: the avatar if there is one, a monogram tile if there is not. */function faceFor(person: FollowedSubscriber): HTMLElement { const name = person.handle ? `@${person.handle}` : person.did const link = document.createElement('a') link.href = bskyProfileUrl(person.handle ?? person.did) link.target = '_blank' link.rel = 'noopener noreferrer' link.title = name
const monogram = () => { const tile = document.createElement('span') tile.className = 'face face-fallback' tile.textContent = [...name.replace(/^@/, '')][0]?.toUpperCase() ?? '·' return tile }
if (!person.avatarUrl) { link.append(monogram()) return link } const img = document.createElement('img') img.className = 'face' img.alt = name // Somebody else's PDS serving somebody else's blob: a miss is ordinary. img.onerror = () => { console.debug('[substandard] subscriber avatar failed to load', person.avatarUrl) img.replaceWith(monogram()) } img.src = person.avatarUrl link.append(img) return link}