// 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(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 { const { pds } = await resolveDid(did) const records = await listRecords<{ subject?: string }>( pds, did, 'app.bsky.graph.follow', FOLLOWS_CAP, ) const dids = new Set() 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 { 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 /** * 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 { const cap = opts.faces ? SCAN_CAP : 0 let total = 0 let truncated = false const dids = new Set() 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 { 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 } }