diff --git a/README.md b/README.md index 2d5b0e7..8fa4c9e 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,14 @@ substandard shows a badge when the site you're visiting has a standard.site publ publication card, and a label that Bluesky's own interpretation puts behind a click puts the card behind one too, with a "Show anyway" button. A labeler is any account with an `#atproto_labeler` service, so the - extension holds no opinion of its own: it asks the labelers in - `chrome.storage.local` (Bluesky's moderation service by default) about - the publication's account and records, and shows what comes back. The + extension holds no opinion of its own: it asks the labelers *you* + subscribe to about the publication's account and records, and shows what + comes back. The list is your own Bluesky labeler subscriptions, read from + `app.bsky.actor.preferences` — subscribe to a labeler in any client and + its labels appear here, with no substandard-specific list to maintain. + Signed out, or with a session that predates the scope, it falls back to + the `labelers` key in `chrome.storage.local` and then to Bluesky's own + moderation service. The severity, blur and wording all come from Bluesky — `LABELS` and `interpretLabelValueDefinition` in `@atproto/api`, plus the global label strings — so a label you recognize from Bluesky reads the same here. See diff --git a/TODO.md b/TODO.md index 902f91b..ee12b4a 100644 --- a/TODO.md +++ b/TODO.md @@ -89,9 +89,10 @@ Which shots appear is the same decision: the list is three today in `docs/status-states.md`. The layout centers a lone last shot, so an odd count is fine. -## Ship the feedback scope with a metadata deploy +## Ship the feedback and preferences scopes with a metadata deploy -The feedback form asks for `include:app.userinput.authBasic`, which lives in +The feedback form asks for `include:app.userinput.authBasic` and the labeler +list asks for `rpc:app.bsky.actor.getPreferences?aud=*`. Both live in `oauth/client-metadata.json` — the file PDSes read from `https://substandard.blog/client-metadata.json`. Two consequences at release time: @@ -105,6 +106,10 @@ time: - Sessions granted under the old list cannot post; the panel says so when the PDS refuses the write. Nothing prompts for re-consent on its own — decide whether that is worth a status pill once there are users with old sessions. + The labeler list fails more quietly on the same session: it falls back to + the local list and logs, because a label is advisory and a wrong-looking + set of labels is better than none. If the status pill happens, this is a + second reason for it. Deliberately not built: image attachments. The lexicon takes up to four (`app.userinput.discussion` `#image`, 1 MB each), and a screenshot of the @@ -269,8 +274,7 @@ was set from the console. What is missing, cheapest first: ignore/warn/hide; substandard applies the labeler's default. Honouring an override means storing a per-labeler, per-value map and threading it through `viewLabel`, which is where `hides` is decided. -- Reading the user's Bluesky labeler subscriptions instead of a local list. - `app.bsky.actor.getPreferences` holds them (`labelersPref`), but it is an - authenticated call against the user's PDS, so it needs an - `rpc:app.bsky.actor.getPreferences` scope in `oauth/client-metadata.json` - and the metadata deploy that goes with any scope change. +- A way to add a labeler for substandard alone. The subscription list is now + read from the account's Bluesky preferences, which is the right default — + but a labeler you want here and not in Bluesky has nowhere to live except + the `labelers` storage key, which nothing writes. diff --git a/oauth/client-metadata.json b/oauth/client-metadata.json index d3e27d1..e39dc79 100644 --- a/oauth/client-metadata.json +++ b/oauth/client-metadata.json @@ -7,7 +7,7 @@ "https://mecbfognmmefgjekidnddjjlddnfnlki.chromiumapp.org/oauth2", "https://degljbilkggdpbobomfbgnellecgbkjj.chromiumapp.org/oauth2" ], - "scope": "atproto repo:site.standard.graph.subscription include:app.userinput.authBasic", + "scope": "atproto repo:site.standard.graph.subscription include:app.userinput.authBasic rpc:app.bsky.actor.getPreferences?aud=*", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/src/lib/labels.test.ts b/src/lib/labels.test.ts index 5487456..8bcce52 100644 --- a/src/lib/labels.test.ts +++ b/src/lib/labels.test.ts @@ -3,6 +3,7 @@ // retracting, and an unreachable labeler costing nothing but a log. import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@atproto/api' import { DEFAULT_LABELER_DID, type LabelerInfo, @@ -10,7 +11,9 @@ import { labelerDids, labelsFor, queryLabelsUrl, + subscribedLabelerDids, viewLabel, + withDefaultLabeler, } from './labels' const LABELER = 'did:plc:labeler0000000000000000' @@ -180,3 +183,55 @@ function stubNetwork() { }), ) } + +/** Just the one call subscribedLabelerDids makes. */ +function agentWith(preferences: unknown[]): Agent { + return { + app: { bsky: { actor: { getPreferences: async () => ({ data: { preferences } }) } } }, + } as unknown as Agent +} + +describe('subscribedLabelerDids', () => { + it('reads the account’s own labeler subscriptions out of its preferences', async () => { + const dids = await subscribedLabelerDids( + agentWith([ + { $type: 'app.bsky.actor.defs#savedFeedsPrefV2', items: [] }, + { + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [{ did: LABELER }, { did: 'did:plc:another0000000000000000' }], + }, + ]), + ) + expect(dids).toEqual([LABELER, 'did:plc:another0000000000000000']) + }) + + it('is empty for an account that has subscribed to none', async () => { + expect(await subscribedLabelerDids(agentWith([]))).toEqual([]) + }) + + it('drops entries that are not dids, since preferences are user-written', async () => { + const dids = await subscribedLabelerDids( + agentWith([ + { + $type: 'app.bsky.actor.defs#labelersPref', + labelers: [{ did: LABELER }, { did: 'mod.example.com' }, {}, { did: 42 }], + }, + ]), + ) + expect(dids).toEqual([LABELER]) + }) +}) + +describe('withDefaultLabeler', () => { + it('always asks Bluesky moderation, which cannot be unsubscribed there', () => { + expect(withDefaultLabeler([LABELER])).toEqual([DEFAULT_LABELER_DID, LABELER]) + expect(withDefaultLabeler([])).toEqual([DEFAULT_LABELER_DID]) + }) + + it('does not ask it twice when it is in the subscriptions too', () => { + expect(withDefaultLabeler([DEFAULT_LABELER_DID, LABELER])).toEqual([ + DEFAULT_LABELER_DID, + LABELER, + ]) + }) +}) diff --git a/src/lib/labels.ts b/src/lib/labels.ts index 26d0a3a..c209fd8 100644 --- a/src/lib/labels.ts +++ b/src/lib/labels.ts @@ -18,6 +18,8 @@ // from Bluesky reads the same here. import { + type Agent, + AppBskyActorDefs, BSKY_LABELER_DID, type ComAtprotoLabelDefs, LABELS, @@ -94,12 +96,48 @@ interface LabelerRecord { } } -/** The labelers to ask, from storage; Bluesky's moderation service by default. */ +/** + * 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 { const stored = (await chrome.storage.local.get('labelers')).labelers if (!Array.isArray(stored)) return [DEFAULT_LABELER_DID] - const dids = stored.filter((d): d is string => typeof d === 'string' && d.startsWith('did:')) - return dids + 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 { + 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])] } /** diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 32638f8..c074756 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -10,7 +10,13 @@ import { discussionUrl, fetchSpace, } from '../lib/feedback' -import { type LabelView, labelerDids, labelsFor } from '../lib/labels' +import { + type LabelView, + labelerDids, + labelsFor, + subscribedLabelerDids, + withDefaultLabeler, +} from '../lib/labels' import { refreshStoredSession, restoreAgent, signOut } from '../lib/oauth' import { isScopeError } from '../lib/scope' import { type Owner, fetchOwner, ownerName, ownerSubtitle } from '../lib/profile' @@ -78,7 +84,12 @@ async function invalidateSubsCache() { */ async function invalidateAccountCaches() { if (session) { - await chrome.storage.session.remove([`subs:${session.did}`, `blocks:${session.did}`]) + await chrome.storage.session.remove([ + `subs:${session.did}`, + `blocks:${session.did}`, + // Which labelers to ask is the account's own subscription list. + `labelers:${session.did}`, + ]) } } @@ -558,6 +569,46 @@ function faceFor(person: FollowedSubscriber): HTMLElement { // too, until the reader asks for it. const LABELS_TTL = 10 * 60 * 1000 +const LABELERS_TTL = 30 * 60 * 1000 + +/** + * Which labelers to ask. The account's own Bluesky subscriptions when they + * can be read — subscribe to a labeler in any client and its labels show up + * here — and the local list (or Bluesky's moderation service) when they + * cannot: signed out, offline, or a session granted before this extension + * asked for the preferences scope. + * + * Cached for the browser session, since it is the same answer for every + * publication. The refresh button bypasses it, which is also how a user who + * has just subscribed to a labeler sees its labels without waiting. + */ +async function activeLabelers(refresh: boolean): Promise { + if (!session) return labelerDids() + const key = `labelers:${session.did}` + if (!refresh) { + const cached = (await chrome.storage.session.get(key))[key] as + | { at: number; dids: string[] } + | undefined + if (cached && Date.now() - cached.at < LABELERS_TTL) return cached.dids + } + try { + const agent = await restoreAgent() + if (!agent) throw new Error('no restorable session') + const dids = withDefaultLabeler(await subscribedLabelerDids(agent)) + await chrome.storage.session.set({ [key]: { at: Date.now(), dids } }) + console.debug(`[substandard] ${dids.length} subscribed labeler(s)`) + return dids + } catch (err) { + // Advisory either way, so this falls back rather than surfacing: the + // labels a stale session can still see are better than none. + console.debug( + '[substandard] could not read labeler subscriptions', + isScopeError(err) ? '(session predates the preferences scope)' : '', + err, + ) + return labelerDids() + } +} async function loadLabels(refresh: boolean) { const pub = state?.pub @@ -578,7 +629,7 @@ async function loadLabels(refresh: boolean) { // The record uris as well as the account: a labeler can label one document // without labelling everything its author ever wrote. const subjects = [pub.did, pub.uri, ...(state?.doc ? [state.doc.uri] : [])] - const views = await labelsFor(subjects, await labelerDids()) + const views = await labelsFor(subjects, await activeLabelers(refresh)) await chrome.storage.session.set({ [key]: { at: Date.now(), views } }) labels = { uri: pub.uri, views } renderLabels() @@ -934,12 +985,16 @@ $('signout').addEventListener('click', async () => { await invalidateAccountCaches() await signOut() session = undefined - // The faces were the old account's follows; drop them and ask again as nobody. + // The faces were the old account's follows and the labels came from its + // labeler subscriptions; drop both and ask again as nobody. subscribers = undefined subscribersFor = undefined + labels = undefined + labelsLoadedFor = undefined renderAccount() render() void loadSubscribers(false) + void loadLabels(false) }) $('refresh').addEventListener('click', () => loadState(true))