Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
11 kB · 268 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269// Who else subscribes to a publication.//// Subscriptions are public records in other people's repos, so the answer is// already on the network; what is missing is an index that can go from a// publication to the records pointing at it. Constellation (microcosm's free// backlink index) is that index, and it is the only third-party service here:// the follow set comes from the signed-in account's own PDS, and each face is// hydrated from its owner's PDS, so no appview is in the path.//// Everything in this module is cosmetic. Constellation being down, slow, or// unaware of a publication must cost the row, never the popup, so callers get// null instead of an error and the failure is a debug log.
import { listRecords, parseAtUri, profileAvatarUrl, resolveDid } from './atproto'import { type RequestOptions, requestJson } from './http'
export const CONSTELLATION = 'https://constellation.microcosm.blue'
const SUB_COLLECTION = 'site.standard.graph.subscription'/** The subscription record field holding the publication's at-uri. */const SUB_PATH = '.publication'
/** Faces we draw. More than this is a crowd, not information. */export const FACES_MAX = 5
/** * How many subscriber DIDs we will pull before giving up on finding more of * the user's follows among them. Every publication in the wild today has far * fewer subscribers than this (the largest we measured had 53), so the cap * only ever bites on a publication popular enough that the exact set of * followed subscribers stops being the interesting part. */export const SCAN_CAP = 500
const PAGE = 100
/** Third-party and optional: fail fast rather than hold the row open. */const OPTIONS: RequestOptions = { timeoutMs: 5_000, retries: 1 }
export interface FollowedSubscriber { did: string handle?: string avatarUrl?: string}
export interface SubscriberSummary { /** Distinct accounts with a subscription record pointing at this publication. */ total: number /** Of those, accounts the signed-in user follows. At most FACES_MAX. */ followed: FollowedSubscriber[] /** How many followed subscribers there were, including the ones past FACES_MAX. */ followedTotal: number /** True when there were more subscribers than SCAN_CAP left to look through. */ truncated: boolean /** * True when the viewer's own follow set stopped at FOLLOWS_CAP. The other * end of the same intersection: this caps who could be recognised, rather * than who was looked at. Optional because entries cached before it existed * do not carry it. */ followsTruncated?: boolean}
/** * What Constellation alone can say: the count, and the subscriber DIDs behind * it. Split from the summary because the two halves have different costs and * different prerequisites — the count is in the index's first response and * needs no viewer, while the faces need the viewer's whole follow list, which * is the slowest read in the extension (see src/background.ts). */export interface SubscriberScan { total: number /** Distinct subscriber DIDs, up to SCAN_CAP. Empty when no faces were asked for. */ dids: string[] /** True when there were more subscribers than SCAN_CAP left to look through. */ truncated: boolean}
interface DistinctDids { total: number linking_dids: string[] cursor: string | null}
/** * Both at-uri forms a subscription may name this publication by. Clients * write whichever form they were handed: substandard normalizes to the DID * before writing, but a publication's own well-known can answer with a * handle-form uri, and a client that stores that verbatim * files its subscriptions under a different target string. Constellation * matches targets exactly, so asking for one form alone silently loses the * other's subscribers. */export function subscriptionTargets(pubUri: string, handle?: string): string[] { const targets = [pubUri] const parsed = parseAtUri(pubUri) if (handle && parsed) targets.push(`at://${handle}/${parsed.collection}/${parsed.rkey}`) return targets}
export function distinctDidsUrl(target: string, cursor?: string): string { const u = new URL(`${CONSTELLATION}/links/distinct-dids`) u.searchParams.set('target', target) u.searchParams.set('collection', SUB_COLLECTION) u.searchParams.set('path', SUB_PATH) u.searchParams.set('limit', String(PAGE)) if (cursor) u.searchParams.set('cursor', cursor) return u.toString()}
/** * Every DID that subscribes under `target`, up to `cap`. `truncated` says the * index had more to give, which is the difference between "nobody else" and * "nobody else in the part we looked at". */async function subscriberDids( target: string, cap: number,): Promise<{ dids: string[]; total: number; truncated: boolean }> { const dids: string[] = [] let total = 0 let cursor: string | undefined do { const page = await requestJson<DistinctDids>(distinctDidsUrl(target, cursor), OPTIONS) total = page.total ?? 0 dids.push(...(page.linking_dids ?? [])) cursor = page.cursor ?? undefined } while (cursor && dids.length < cap) return { dids: dids.slice(0, cap), total, truncated: !!cursor && dids.length >= cap }}
/** * How many follow records one walk will read. A hundred per request, so this * is a hundred requests against the account's own PDS, once a day, in the * background worker (src/background.ts). * * The number is set by how long that worker can be relied on to live, not by * politeness to the PDS: an MV3 service worker stays up while its event is * being handled, and a walk that runs past that is killed with nothing cached * and nothing to show for it. Ten thousand covers all but a small tail of * accounts inside a wait that comfortably fits. Going further wants a walk * that can resume from a cursor rather than a bigger number here (see * TODO.md). */export const FOLLOWS_CAP = 10_000
export interface FollowList { dids: string[] /** The walk stopped at the cap: this is a prefix of the follows, not all of them. */ truncated: boolean}
/** * The DIDs the account follows, read from its own repo rather than from an * appview. `truncated` is the part that used to be invisible: past the cap the * set is a prefix, and a caller that reads absence from it — the owner card's * "Following" line — would otherwise turn "we stopped looking" into "no". */export async function followingDids(did: string): Promise<FollowList> { const { pds } = await resolveDid(did) const records = await listRecords<{ subject?: string }>( pds, did, 'app.bsky.graph.follow', FOLLOWS_CAP, ) const dids = new Set<string>() for (const r of records) if (r.value.subject) dids.add(r.value.subject) return { dids: [...dids], truncated: records.length >= FOLLOWS_CAP }}
/** Handle and avatar for a face, from that account's own PDS. Never throws. */async function hydrate(did: string): Promise<FollowedSubscriber> { try { const { pds, handle } = await resolveDid(did) return { did, handle, avatarUrl: await profileAvatarUrl(pds, did) } } catch (err) { console.debug('[substandard] could not hydrate subscriber', did, err) return { did } }}
export interface Viewer { did: string /** * Who they follow. Passed in rather than read here: the walk is one request * per hundred follows, it is the same answer for every publication, and the * popup can cache it for the session (see loadSubscribers in popup.ts). */ following: Set<string> /** * The set stopped at FOLLOWS_CAP, so it is a prefix. Absence from it means * "not in the part we read", which is not the same as "not followed". */ truncated?: boolean}
/** * How many accounts subscribe, and — when `faces` is set — who they are. The * count comes back in the index's first response either way, so a caller that * cannot draw faces (nobody signed in) asks for one request per target form * instead of paging to SCAN_CAP for DIDs it would throw away. * * Null means the question could not be answered at all; the caller hides the * row rather than showing a zero it does not believe. */export async function scanSubscribers( pubUri: string, handle?: string, opts: { faces?: boolean } = {},): Promise<SubscriberScan | null> { const cap = opts.faces ? SCAN_CAP : 0 let total = 0 let truncated = false const dids = new Set<string>() try { for (const target of subscriptionTargets(pubUri, handle)) { const page = await subscriberDids(target, cap) // Summed, not unioned: the same account subscribing under both target // forms would be counted twice. Two records is what the network holds, // and asking for the union would mean paging every subscriber to find // the overlap for a number nobody reads that closely. total += page.total truncated ||= page.truncated for (const did of page.dids) dids.add(did) } } catch (err) { console.debug('[substandard] constellation lookup failed for', pubUri, err) return null } // Truncation is a statement about the faces, so a scan that was never going // to draw any does not make it. return { total, dids: [...dids], truncated: cap > 0 && truncated }}
/** * Which of a scan's subscribers the viewer follows, with a face for each. * Separate from the scan because it is what waits on the follow set: the * caller draws the count first and the faces when this lands. */export async function followedSubscribers( scan: SubscriberScan, viewer: Viewer,): Promise<{ followed: FollowedSubscriber[]; followedTotal: number }> { const matched = scan.dids.filter((did) => did !== viewer.did && viewer.following.has(did)) const followed = await Promise.all(matched.slice(0, FACES_MAX).map(hydrate)) return { followed, followedTotal: matched.length }}
/** * The whole subscriber row in one call: the count, and which subscribers the * viewer follows. No viewer (signed out, or the follow set could not be read) * still gets the total — the count is public either way. * * The popup does not use this; it draws the two halves as they arrive. This is * the composed answer, for callers that only want the finished one. */export async function summarizeSubscribers( pubUri: string, handle?: string, viewer?: Viewer,): Promise<SubscriberSummary | null> { const scan = await scanSubscribers(pubUri, handle, { faces: !!viewer }) if (!scan) return null const none = { total: scan.total, followed: [], followedTotal: 0, truncated: scan.truncated } if (!viewer || scan.dids.length === 0) return none return { total: scan.total, ...(await followedSubscribers(scan, viewer)), truncated: scan.truncated }}