[SDK] Route moderation reports through the appview client (#11368)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:18 +03:00
committed by GitHub
parent 34d1c75756
commit d991118215
7 changed files with 127 additions and 87 deletions
@@ -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 => {
+30 -18
View File
@@ -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.`,
@@ -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<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & {
subject:
| $Typed<ChatBskyConvoDefs.MessageRef>
| $Typed<ChatBskyConvoDefs.ConvoRef>
})
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']
}
@@ -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},
)
}
},
+12 -4
View File
@@ -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: <this
* value>` 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`,
}
@@ -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 => {
+12 -11
View File
@@ -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(''),