diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx index 1ee126eb28..59df413324 100644 --- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -1,13 +1,14 @@ import {useState} from 'react' import {View} from 'react-native' import {ToolsOzoneReportDefs} from '@atproto/api' +import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation} from '@tanstack/react-query' -import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' -import {useAgent, useSession} from '#/state/session' +import {MOD_PROXY_SERVICE} from '#/lib/constants' +import {useAppviewClient, useSession} from '#/state/session' import {atoms as a, useBreakpoints, web} from '#/alf' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -17,6 +18,7 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics' +import {com} from '#/lexicons' export function AgeAssuranceAppealDialog({ control, @@ -42,7 +44,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) { const ax = useAnalytics() const {currentAccount} = useSession() const {gtPhone} = useBreakpoints() - const agent = useAgent() + const client = useAppviewClient() const [details, setDetails] = useState('') const isInvalid = details.length > 1000 @@ -51,19 +53,22 @@ function Inner({control}: {control: Dialog.DialogControlProps}) { mutationFn: async () => { ax.metric('ageAssurance:appealDialogSubmit', {}) - await agent.createModerationReport( + if (!currentAccount) { + throw new Error('No current account, should be unreachable') + } + + await client.call( + com.atproto.moderation.createReport, { reasonType: ToolsOzoneReportDefs.REASONAPPEAL, subject: { $type: 'com.atproto.admin.defs#repoRef', - did: currentAccount?.did, + // the persisted account did is already resolved + did: currentAccount.did as DidString, }, reason: `AGE_ASSURANCE_INQUIRY: ` + details, }, - { - encoding: 'application/json', - headers: BLUESKY_MOD_SERVICE_HEADERS, - }, + {service: MOD_PROXY_SERVICE}, ) }, onError: err => { diff --git a/src/components/moderation/AppealForm.tsx b/src/components/moderation/AppealForm.tsx index 5432672d1e..606e8162ec 100644 --- a/src/components/moderation/AppealForm.tsx +++ b/src/components/moderation/AppealForm.tsx @@ -1,7 +1,8 @@ import {useState} from 'react' import {View} from 'react-native' import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api' -import {XRPCError} from '@atproto/api' +import {XrpcResponseError} from '@atproto/lex' +import {type AtUriString, type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -12,7 +13,7 @@ import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' import {atoms as a, useBreakpoints} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -22,6 +23,7 @@ import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {IS_ANDROID} from '#/env' +import {com} from '#/lexicons' export function AppealForm({ label, @@ -38,7 +40,7 @@ export function AppealForm({ const [details, setDetails] = useState('') const {subject} = useLabelSubject({label}) const isAccountReport = 'did' in subject - const agent = useAgent() + const client = useAppviewClient() const sourceName = labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src @@ -46,28 +48,38 @@ export function AppealForm({ const {mutate, isPending} = useMutation({ mutationFn: async () => { - const $type = !isAccountReport - ? 'com.atproto.repo.strongRef' - : 'com.atproto.admin.defs#repoRef' - await agent.createModerationReport( + await client.call( + com.atproto.moderation.createReport, { reasonType: ToolsOzoneReportDefs.REASONAPPEAL, - subject: { - $type, - ...subject, - }, + /* + * `useLabelSubject` derives one shape or the other from the label's + * `cid`: an at-uri plus cid for a record, or the label's `uri` reused + * as the account did. + */ + subject: isAccountReport + ? { + $type: 'com.atproto.admin.defs#repoRef', + did: subject.did as DidString, + } + : { + $type: 'com.atproto.repo.strongRef', + uri: subject.uri as AtUriString, + cid: subject.cid, + }, reason: details, }, - { - encoding: 'application/json', - headers: { - 'atproto-proxy': `${label.src}#atproto_labeler`, - }, - }, + // the appeal goes to the labeler that applied the label + {service: `${label.src as DidString}#atproto_labeler`}, ) }, onError: err => { - if (err instanceof XRPCError && err.error === 'AlreadyAppealed') { + /* + * `AlreadyAppealed` is real server behavior that createReport's lexicon + * does NOT declare, so `matchXrpcError` cannot see it and the raw error + * code is checked instead. Worth an upstream PR to declare it. + */ + if (err instanceof XrpcResponseError && err.error === 'AlreadyAppealed') { setError( _( msg`You've already appealed this label and it's being reviewed by our moderation team.`, diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts index 20303ddef5..12b483fcab 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -1,22 +1,21 @@ -import { - type $Typed, - BSKY_LABELER_DID, - type ChatBskyConvoDefs, - type ComAtprotoModerationCreateReport, -} from '@atproto/api' +import {BSKY_LABELER_DID} from '@atproto/api' +import {type AtUriString, type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useMutation} from '@tanstack/react-query' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' +import {com} from '#/lexicons' import {NEW_TO_OLD_REASONS_MAP, REPORT_MOD_TOOL_NAME} from './const' import {type ReportState} from './state' import {type ParsedReportSubject} from './types' +type ReportInput = com.atproto.moderation.createReport.$InputBody + export function useSubmitReportMutation() { const {_} = useLingui() - const agent = useAgent() + const client = useAppviewClient() return useMutation({ async mutationFn({ @@ -59,13 +58,7 @@ export function useSubmitReportMutation() { reasonType = backwardsCompatibleReasonType } - let report: - | ComAtprotoModerationCreateReport.InputSchema - | (Omit & { - subject: - | $Typed - | $Typed - }) + let report: ReportInput switch (subject.type) { case 'account': { @@ -74,7 +67,8 @@ export function useSubmitReportMutation() { reason: state.details, subject: { $type: 'com.atproto.admin.defs#repoRef', - did: subject.did, + // the parsed subject carries an already-resolved did + did: subject.did as DidString, }, } break @@ -89,7 +83,8 @@ export function useSubmitReportMutation() { reason: state.details, subject: { $type: 'com.atproto.repo.strongRef', - uri: subject.uri, + // the parsed subject carries an at-uri read off a view + uri: subject.uri as AtUriString, cid: subject.cid, }, } @@ -99,12 +94,12 @@ export function useSubmitReportMutation() { report = { reasonType, reason: state.details, - subject: { + subject: toOpenSubject({ $type: 'chat.bsky.convo.defs#messageRef', messageId: subject.message.id, convoId: subject.convoId, did: subject.message.sender.did, - }, + }), } break } @@ -112,11 +107,11 @@ export function useSubmitReportMutation() { report = { reasonType, reason: state.details, - subject: { + subject: toOpenSubject({ $type: 'chat.bsky.convo.defs#convoRef', convoId: subject.convoId, did: subject.did, - }, + }), } break } @@ -145,13 +140,30 @@ export function useSubmitReportMutation() { report, }) } else { - await agent.createModerationReport(report, { - encoding: 'application/json', - headers: { - 'atproto-proxy': `${labeler.creator.did}#atproto_labeler`, - }, + /* + * Reports go to the labeler the user selected rather than Bluesky's, so + * the proxy target is built per call from that labeler's creator did. + */ + await client.call(com.atproto.moderation.createReport, report, { + service: `${labeler.creator.did as DidString}#atproto_labeler`, }) } }, }) } + +/** + * Widen a chat convo ref into `createReport`'s subject union. + * + * The lexicon declares only `com.atproto.admin.defs#repoRef` and + * `com.atproto.repo.strongRef`, but leaves the union OPEN, and the chat service + * accepts its own refs there. An open union types its unknown arm as + * `{$type: Unknown$Type}`, which a concrete chat ref does not structurally + * satisfy, so the widening is asserted here once rather than at each call site. + */ +function toOpenSubject(ref: { + $type: string + [key: string]: unknown +}): ReportInput['subject'] { + return ref as unknown as ReportInput['subject'] +} diff --git a/src/features/liveNow/components/GoLiveDisabledDialog.tsx b/src/features/liveNow/components/GoLiveDisabledDialog.tsx index 7eeccdc22b..fda33a9067 100644 --- a/src/features/liveNow/components/GoLiveDisabledDialog.tsx +++ b/src/features/liveNow/components/GoLiveDisabledDialog.tsx @@ -1,20 +1,22 @@ import {useCallback, useState} from 'react' import {View} from 'react-native' import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api' +import {type AtUriString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation} from '@tanstack/react-query' -import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' +import {MOD_PROXY_SERVICE} from '#/lib/constants' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {useAppviewClient} from '#/state/session' import {atoms as a, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {com} from '#/lexicons' export function GoLiveDisabledDialog({ control, @@ -39,12 +41,12 @@ export function DialogInner({ status: AppBskyActorDefs.StatusView }) { const {_} = useLingui() - const agent = useAgent() + const client = useAppviewClient() const [details, setDetails] = useState('') const {mutate, isPending} = useMutation({ mutationFn: async () => { - if (!agent.session?.did) { + if (!client.did) { throw new Error('Not logged in') } if (!status.uri || !status.cid) { @@ -56,20 +58,19 @@ export function DialogInner({ details, }) } else { - await agent.createModerationReport( + await client.call( + com.atproto.moderation.createReport, { reasonType: ToolsOzoneReportDefs.REASONAPPEAL, subject: { $type: 'com.atproto.repo.strongRef', - uri: status.uri, + // a status view's uri is an at-uri produced by the appview + uri: status.uri as AtUriString, cid: status.cid, }, reason: details, }, - { - encoding: 'application/json', - headers: BLUESKY_MOD_SERVICE_HEADERS, - }, + {service: MOD_PROXY_SERVICE}, ) } }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a89fa5b5e..156712c809 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -263,14 +263,22 @@ export const BLUESKY_PROXY_HEADER = { */ export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat` +/** + * Bluesky's own moderation service, in the `did#service_id` form a lex client's + * per-call `service` option takes. Passing it emits `atproto-proxy: ` on that one request, routing a `com.atproto.moderation.*` call to + * Bluesky's labeler. + * + * Reports and appeals aimed at a DIFFERENT labeler build their own value from + * that labeler's creator did instead, so this is a per-call option rather than a + * client-level one like {@link CHAT_PROXY_SERVICE}. + */ +export const MOD_PROXY_SERVICE: Service = `${BSKY_LABELER_DID}#atproto_labeler` + export const DM_SERVICE_HEADERS = { 'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`, } -export const BLUESKY_MOD_SERVICE_HEADERS = { - 'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`, -} - export const BLUESKY_NOTIF_SERVICE_HEADERS = { 'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`, } diff --git a/src/screens/Messages/components/ChatDisabled.tsx b/src/screens/Messages/components/ChatDisabled.tsx index 8095c1e0b9..62f353d1a8 100644 --- a/src/screens/Messages/components/ChatDisabled.tsx +++ b/src/screens/Messages/components/ChatDisabled.tsx @@ -1,12 +1,13 @@ import {useCallback, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' import {ToolsOzoneReportDefs} from '@atproto/api' +import {type DidString} from '@atproto/syntax' import {Trans, useLingui} from '@lingui/react/macro' import {useMutation} from '@tanstack/react-query' -import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' +import {MOD_PROXY_SERVICE} from '#/lib/constants' import {logger} from '#/logger' -import {useAgent, useSession} from '#/state/session' +import {useAppviewClient, useSession} from '#/state/session' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -14,6 +15,7 @@ import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {com} from '#/lexicons' export function ChatDisabled({ shape = 'pill', @@ -92,26 +94,25 @@ function DialogInner() { const control = Dialog.useDialogContext() const [details, setDetails] = useState('') const {gtMobile} = useBreakpoints() - const agent = useAgent() + const client = useAppviewClient() const {currentAccount} = useSession() const {mutate, isPending} = useMutation({ mutationFn: async () => { if (!currentAccount) throw new Error('No current account, should be unreachable') - await agent.createModerationReport( + await client.call( + com.atproto.moderation.createReport, { reasonType: ToolsOzoneReportDefs.REASONAPPEAL, subject: { $type: 'com.atproto.admin.defs#repoRef', - did: currentAccount.did, + // the persisted account did is already resolved + did: currentAccount.did as DidString, }, reason: details, }, - { - encoding: 'application/json', - headers: BLUESKY_MOD_SERVICE_HEADERS, - }, + {service: MOD_PROXY_SERVICE}, ) }, onError: err => { diff --git a/src/screens/Takendown.tsx b/src/screens/Takendown.tsx index 5ad254fd1c..5873b00473 100644 --- a/src/screens/Takendown.tsx +++ b/src/screens/Takendown.tsx @@ -2,7 +2,8 @@ import {useState} from 'react' import {View} from 'react-native' import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {type ComAtprotoAdminDefs, ToolsOzoneReportDefs} from '@atproto/api' +import {ToolsOzoneReportDefs} from '@atproto/api' +import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -10,11 +11,11 @@ import {useMutation} from '@tanstack/react-query' import {countGraphemes} from 'unicode-segmenter/grapheme' import { - BLUESKY_MOD_SERVICE_HEADERS, MAX_REPORT_REASON_GRAPHEME_LENGTH, + MOD_PROXY_SERVICE, } from '#/lib/constants' import {cleanError} from '#/lib/strings/errors' -import {useAgent, useSession, useSessionApi} from '#/state/session' +import {useAppviewClient, useSession, useSessionApi} from '#/state/session' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {Logo} from '#/view/icons/Logo' import {atoms as a, useBreakpoints, useTheme} from '#/alf' @@ -24,6 +25,7 @@ import {SimpleInlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {P, Text} from '#/components/Typography' import {IS_WEB} from '#/env' +import {com} from '#/lexicons' const COL_WIDTH = 400 @@ -34,7 +36,7 @@ export function Takendown() { const {gtMobile} = useBreakpoints() const {currentAccount} = useSession() const {logoutCurrentAccount} = useSessionApi() - const agent = useAgent() + const client = useAppviewClient() const [isAppealling, setIsAppealling] = useState(false) const [reason, setReason] = useState('') @@ -50,19 +52,18 @@ export function Takendown() { } = useMutation({ mutationFn: async (appealText: string) => { if (!currentAccount) throw new Error('No session') - await agent.com.atproto.moderation.createReport( + await client.call( + com.atproto.moderation.createReport, { reasonType: ToolsOzoneReportDefs.REASONAPPEAL, subject: { $type: 'com.atproto.admin.defs#repoRef', - did: currentAccount.did, - } satisfies ComAtprotoAdminDefs.RepoRef, + // the persisted account did is already resolved + did: currentAccount.did as DidString, + }, reason: appealText, }, - { - encoding: 'application/json', - headers: BLUESKY_MOD_SERVICE_HEADERS, - }, + {service: MOD_PROXY_SERVICE}, ) }, onSuccess: () => setReason(''),