diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 912a87a1ba..d94342e40b 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -38,7 +38,7 @@ import { type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' import {createQueryKey} from '#/state/queries/util' -import {useAppviewClient, usePdsClient} from '#/state/session' +import {useAppviewClient, useChatClient, usePdsClient} from '#/state/session' import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation' import {useAgeAssurance} from '#/ageAssurance' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' @@ -58,6 +58,7 @@ export const preferencesQueryKey = createQueryKey( export function usePreferencesQuery() { const client = usePdsClient() const appviewClient = useAppviewClient() + const chatClient = useChatClient() const aa = useAgeAssurance() const query = useQuery({ @@ -85,12 +86,12 @@ export function usePreferencesQuery() { * from a labeler would not affect server-attached labels until the * session bundle was rebuilt. * - * The subscriptions go on the appview client, which is what stamps - * `atproto-accept-labelers` on its own requests. The Bluesky moderation - * DID is dropped so the globally redacted authority is not also listed - * unredacted. + * The subscriptions go on both services that hydrate moderated content. + * The Bluesky moderation DID is dropped so the globally redacted + * authority is not also listed unredacted. */ applyLabelersToClient(appviewClient, labelerDids) + applyLabelersToClient(chatClient, labelerDids) /* * `BskyPreferences` is now the sdk's own type, so the assembled diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 50cf70fede..ec0e8d99c4 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -251,19 +251,35 @@ describe('buildChatClient', () => { expect(headers.get('authorization')).toBe('Bearer access-jwt') }) - it('emits no labeler header', async () => { - /* the global authorities do not apply: a chat call is not an appview read */ + it('emits a global app labeler once, redacted', async () => { + const client = buildChatClient(makeSession(fetchMock)) configureGlobalAppLabelers(['did:plc:global-labeler']) - await buildChatClient(makeSession(fetchMock)) - .call(chat.bsky.convo.listConvos, {}) - .catch(() => {}) + await client.call(chat.bsky.convo.listConvos, {}).catch(() => {}) - expect( - headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( - 'atproto-accept-labelers', - ), - ).toBeNull() + const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( + 'atproto-accept-labelers', + ) + const entries = labelers! + .split(',') + .map(l => l.trim()) + .filter(l => l.includes('did:plc:global-labeler')) + expect(entries).toEqual(['did:plc:global-labeler;redact']) + }) + + it('emits an account subscription exactly once', async () => { + const client = buildChatClient(makeSession(fetchMock)) + client.setLabelers(['did:plc:labeler']) + + await client.call(chat.bsky.convo.listConvos, {}).catch(() => {}) + + const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( + 'atproto-accept-labelers', + ) + const entries = labelers! + .split(',') + .filter(l => l.includes('did:plc:labeler')) + expect(entries).toHaveLength(1) }) }) diff --git a/src/state/session/__tests__/moderation-test.ts b/src/state/session/__tests__/moderation-test.ts new file mode 100644 index 0000000000..4c7b1c8263 --- /dev/null +++ b/src/state/session/__tests__/moderation-test.ts @@ -0,0 +1,45 @@ +import {type Client} from '@atproto/lex' +import {api} from '@bsky/sdk' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +jest.mock('#/storage', () => ({ + account: { + get: jest.fn(), + set: jest.fn(), + }, + device: { + get: jest.fn(), + }, +})) + +import {account} from '#/storage' +import {configureGlobalAppLabelers} from '../additional-moderation-authorities' +import {configureModerationForAccount} from '../moderation' +import {makeAccount} from './mock-fetch' + +describe('configureModerationForAccount', () => { + beforeEach(() => { + jest.clearAllMocks() + configureGlobalAppLabelers([]) + }) + + it('applies cached account labelers to appview and chat', () => { + const appviewClient = {setLabelers: jest.fn()} as unknown as Client + const chatClient = {setLabelers: jest.fn()} as unknown as Client + jest + .mocked(account.get) + .mockReturnValue(['did:plc:account-labeler', api.moderation.did]) + + configureModerationForAccount( + {appviewClient, chatClient}, + makeAccount({handle: 'alice.example.com'}), + ) + + expect(appviewClient.setLabelers).toHaveBeenCalledWith([ + 'did:plc:account-labeler', + ]) + expect(chatClient.setLabelers).toHaveBeenCalledWith([ + 'did:plc:account-labeler', + ]) + }) +}) diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 76d1039610..8d3e7e4d00 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -98,10 +98,10 @@ export function configureAdditionalModerationAuthorities() { * reads, so a request carries the same `;redact` authorities whether or not * there is a session behind it. * - * It is a single global producer by design. The PDS and chat clients opt out - * with `appLabelers: null` (see `clients.ts`) because those services take no - * moderation authorities, leaving exactly one producer on an appview request and - * none elsewhere. + * It is a single global producer by design. The PDS client opts out with + * `appLabelers: null` (see `clients.ts`) because that service takes no + * moderation authorities. Appview and chat requests each carry one copy from + * their respective clients. */ export function configureGlobalAppLabelers(dids: string[]) { Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 026b2d8bd8..b1ba60be9e 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -54,12 +54,13 @@ export function buildPdsClient(agent: Agent): Client { * env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can * be retargeted per environment. * - * `appLabelers: null` for the same reason as the PDS client: the chat service - * takes no moderation authorities. + * Unlike the PDS client, chat carries moderation authorities. The service uses + * them to hydrate labels on profiles embedded in conversation responses, so + * this client reads the global `Client.appLabelers` and receives the account's + * subscriptions through `configureModerationForAccount`. */ export function buildChatClient(agent: Agent): Client { return createLexClient(agent, { - appLabelers: null, service: CHAT_PROXY_SERVICE, }) } diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 72afe204c2..40725ce11b 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -12,7 +12,7 @@ import { import {type SessionAccount} from './types' /** The moderation surface of a session bundle. */ -type ModerationSession = {appviewClient: Client} +type ModerationSession = {appviewClient: Client; chatClient: Client} /** * Cache an account's subscribed labeler DIDs. Called on every preferences @@ -45,8 +45,8 @@ export function readLabelers(did: string): string[] | undefined { * lex collects the two lists into a `Set` keyed on the suffixed string, so * neither dedupes against the other. * - * Only the appview client takes subscriptions - the PDS and chat clients suppress - * labelers entirely (see clients.ts). + * Appview and chat both take subscriptions. The PDS suppresses labelers because + * repo and identity requests do not hydrate moderated content (see clients.ts). */ export function applyLabelersToClient( client: Client, @@ -85,6 +85,7 @@ export function configureModerationForAccount( const labelerDids = readLabelers(account.did) if (labelerDids) { applyLabelersToClient(bundle.appviewClient, labelerDids) + applyLabelersToClient(bundle.chatClient, labelerDids) } else { // If there are no headers in the storage, we'll not send them on the initial requests. // If we wanted to fix this, we could block on the preferences query here.