Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
10 kB · 255 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256// Moderation labels on a publication, from labelers the user has chosen to// listen to.//// A labeler is an ordinary atproto account that also names an// `#atproto_labeler` service in its DID document and publishes an// `app.bsky.labeler.service` record describing the values it emits. Anyone can// run one, nobody has to listen to one, and the extension holds no opinion of// its own: it asks the labelers in storage (Bluesky's own moderation service// until the user changes it) what they say about this publication's account// and records, and renders the answer.//// The interpretation is Bluesky's rather than ours. `LABELS` and// `interpretLabelValueDefinition` come from @atproto/api — already a// dependency — so a global value like `porn` carries the same severity and// blur behaviour here as it does in the Bluesky app, and a labeler's own// values carry the ones it declared. The strings for the global values are// copied from bluesky-social/social-app (MIT), so a label a user recognizes// from Bluesky reads the same here.
import { type Agent, AppBskyActorDefs, BSKY_LABELER_DID, type ComAtprotoLabelDefs, LABELS, interpretLabelValueDefinition,} from '@atproto/api'import { getRecord, parseAtUri, resolveDid, resolveLabelerEndpoint } from './atproto'import { type RequestOptions, requestJson } from './http'
/** * Bluesky's own moderation service: the labeler nearly everyone already has, * and the one the extension listens to until the user says otherwise. Its DID * comes from @atproto/api rather than being pinned here. */export const DEFAULT_LABELER_DID = BSKY_LABELER_DID
export const LABELER_COLLECTION = 'app.bsky.labeler.service'
/** Third-party and advisory: fail fast rather than hold the card open. */const OPTIONS: RequestOptions = { timeoutMs: 5_000, retries: 1 }
/** * Names and descriptions for the label values every labeler may emit, copied * from bluesky-social/social-app `src/lib/moderation/useGlobalLabelStrings.ts` * (MIT). @atproto/api defines what these values *do* but leaves them * unlocalized, and a bare `graphic-media` in a pill is not an explanation. */const GLOBAL_LABEL_STRINGS: Record<string, { name: string; description: string }> = { '!hide': { name: 'Content Blocked', description: 'This content has been hidden by the moderators.', }, '!warn': { name: 'Content Warning', description: 'This content has received a general warning from moderators.', }, '!no-unauthenticated': { name: 'Sign-in Required', description: 'This user has requested that their content only be shown to signed-in users.', }, porn: { name: 'Adult Content', description: 'Explicit sexual images.' }, sexual: { name: 'Sexually Suggestive', description: 'Does not include nudity.' }, nudity: { name: 'Non-sexual Nudity', description: 'E.g. artistic nudes.' }, 'graphic-media': { name: 'Graphic Media', description: 'Explicit or potentially disturbing media.', }, gore: { name: 'Graphic Media', description: 'Explicit or potentially disturbing media.' },}
export interface LabelerInfo { did: string /** The labeler's handle, which is the only name it is guaranteed to have. */ handle?: string definitions: Record<string, ReturnType<typeof interpretLabelValueDefinition>>}
export interface LabelView { /** The raw label value, e.g. `spam`. Kept for logs and for unknown values. */ value: string name: string description?: string /** Bluesky's severity for this value: how loud the pill is. */ severity: 'inform' | 'alert' | 'none' /** True when Bluesky's interpretation says to put this behind a click. */ hides: boolean /** Who said so. */ labeler: LabelerInfo}
interface LabelerRecord { policies?: { labelValues?: string[] labelValueDefinitions?: unknown[] }}
/** * The labelers to ask when the account's own subscriptions cannot be read: * the `labelers` key in storage.local, and Bluesky's moderation service if * that key has never been written. An empty stored array means the user * turned labels off, so it is honoured rather than treated as unset. */export async function labelerDids(): Promise<string[]> { const stored = (await chrome.storage.local.get('labelers')).labelers if (!Array.isArray(stored)) return [DEFAULT_LABELER_DID] return stored.filter((d): d is string => typeof d === 'string' && d.startsWith('did:'))}
/** * The labelers the account subscribes to, read from its Bluesky preferences. * * Labeler subscriptions live in `app.bsky.actor.preferences` under * `#labelersPref`, which the PDS serves — so this is the same list the user * ticked in Bluesky (or any other client that writes the preference), and * subscribing to a new labeler anywhere is all it takes for its labels to * appear here. There is no substandard-specific list to keep in sync. * * Throws: the caller decides what an unreadable preference means, and a * session granted before this extension asked for the scope is one of the * ways it can fail (see isScopeError). */export async function subscribedLabelerDids(agent: Agent): Promise<string[]> { const { data } = await agent.app.bsky.actor.getPreferences() const pref = data.preferences.find(AppBskyActorDefs.isLabelersPref) const labelers = pref?.labelers ?? [] return labelers .map((l) => (l as { did?: unknown }).did) .filter((did): did is string => typeof did === 'string' && did.startsWith('did:'))}
/** * Subscriptions as the extension applies them. Bluesky's own moderation * service never appears in `#labelersPref` — it cannot be unsubscribed there, * so its absence means "always on", not "off" — and it is prepended here for * the same reason. */export function withDefaultLabeler(dids: string[]): string[] { return [...new Set([DEFAULT_LABELER_DID, ...dids])]}
/** * A labeler's own description of the values it emits. Read from its record on * its PDS rather than from an appview, so a labeler nobody's appview knows * about still describes itself. */export async function fetchLabeler(did: string): Promise<LabelerInfo> { const { pds, handle } = await resolveDid(did) const info: LabelerInfo = { did, handle, definitions: {} } try { const { value } = await getRecord<LabelerRecord>(pds, did, LABELER_COLLECTION, 'self') for (const def of value.policies?.labelValueDefinitions ?? []) { // Third-party data on its way into the UI; interpret defensively. const interpreted = interpretLabelValueDefinition( def as Parameters<typeof interpretLabelValueDefinition>[0], did, ) if (interpreted?.identifier) info.definitions[interpreted.identifier] = interpreted } } catch (err) { // A labeler with no service record still emits global values, which need // no definition from it. console.debug('[substandard] no labeler service record for', did, err) } return info}
export function queryLabelsUrl(endpoint: string, subjects: string[]): string { const u = new URL(`${endpoint}/xrpc/com.atproto.label.queryLabels`) for (const subject of subjects) u.searchParams.append('uriPatterns', subject) u.searchParams.set('limit', '50') return u.toString()}
/** * What one labeler says about these subjects. Labels are signed statements * about a uri; `neg: true` retracts an earlier one, so the retractions and * everything they retract drop out here rather than in the UI. */export async function fetchLabels( endpoint: string, subjects: string[],): Promise<ComAtprotoLabelDefs.Label[]> { const { labels } = await requestJson<{ labels?: ComAtprotoLabelDefs.Label[] }>( queryLabelsUrl(endpoint, subjects), OPTIONS, ) const negated = new Set( (labels ?? []).filter((l) => l.neg).map((l) => `${l.src}|${l.uri}|${l.val}`), ) return (labels ?? []).filter((l) => !l.neg && !negated.has(`${l.src}|${l.uri}|${l.val}`))}
/** * One label as the popup should show it: Bluesky's own severity and blur for * the global values, the labeler's declaration for its own values, and the * raw value when neither says anything (a labeler may emit a value it never * defined, and dropping it would hide a moderator's judgement). */export function viewLabel( label: ComAtprotoLabelDefs.Label, labeler: LabelerInfo,): LabelView { const global = LABELS[label.val as keyof typeof LABELS] const custom = labeler.definitions[label.val] const strings = GLOBAL_LABEL_STRINGS[label.val] const locale = custom?.locales?.[0] const severity = (custom?.severity ?? global?.severity ?? 'inform') as LabelView['severity'] const blurs = custom?.blurs ?? global?.blurs ?? 'none' const setting = custom?.defaultSetting ?? global?.defaultSetting return { value: label.val, name: locale?.name ?? strings?.name ?? label.val, description: locale?.description ?? strings?.description, severity, // "Behind a click" is the union of the two things Bluesky's own // interpretation can mean by it: a value that blurs the content, and a // value whose default is to hide it outright. hides: blurs === 'content' || setting === 'hide', labeler, }}
const SEVERITY_ORDER: Record<LabelView['severity'], number> = { alert: 0, inform: 1, none: 2 }
/** * Every label the chosen labelers put on this publication — its account and * the records the popup is showing — most severe first. * * Labelers are third parties the user opted into, and a label is advisory, so * one that cannot be reached is dropped with a log rather than failing the * card. An empty array means "nobody said anything", which is also what a * user with no labelers configured gets. */export async function labelsFor(subjects: string[], dids: string[]): Promise<LabelView[]> { const uris = subjects.filter((s) => s.startsWith('did:') || parseAtUri(s)) if (uris.length === 0 || dids.length === 0) return [] const views = await Promise.all( dids.map(async (did) => { try { const [endpoint, labeler] = await Promise.all([ resolveLabelerEndpoint(did), fetchLabeler(did), ]) const labels = await fetchLabels(endpoint, uris) return labels.map((label) => viewLabel(label, labeler)) } catch (err) { console.debug('[substandard] labeler unreachable', did, err) return [] } }), ) return views.flat().sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity])}