From b4dfa3cc22bf3767baf1719a60c6bfca82e741fe Mon Sep 17 00:00:00 2001 From: Austin McKinley <54160+amckinley@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:26:10 -0700 Subject: [PATCH] Collapse label appeal flow into ModerationDetailsDialog (#11091) Co-authored-by: Claude Fable 5 Co-authored-by: Eric Bailey --- src/components/Pills.tsx | 129 +++++++----- src/components/Post/Embed/index.tsx | 6 +- src/components/moderation/AppealForm.tsx | 162 +++++++++++++++ src/components/moderation/LabelsOnMe.tsx | 47 +---- .../moderation/LabelsOnMeDialog.tsx | 186 +++--------------- .../moderation/ModerationDetailsDialog.tsx | 76 ++++++- src/components/moderation/PostAlerts.tsx | 134 ++++++++++++- src/lib/moderation.ts | 15 ++ .../Messages/components/ChatListItem.tsx | 1 - .../Messages/components/MessageInputEmbed.tsx | 2 +- .../components/ThreadItemAnchor.tsx | 5 +- .../PostThread/components/ThreadItemPost.tsx | 3 +- .../components/ThreadItemTreePost.tsx | 3 +- src/screens/Profile/Header/Shell.tsx | 1 - src/view/com/post/Post.tsx | 3 +- src/view/com/posts/PostFeedItem.tsx | 3 +- 16 files changed, 511 insertions(+), 265 deletions(-) create mode 100644 src/components/moderation/AppealForm.tsx diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx index 37e940d206..d2b5eb51c6 100644 --- a/src/components/Pills.tsx +++ b/src/components/Pills.tsx @@ -66,8 +66,60 @@ export function Label({ const isLabeler = Boolean(desc.sourceType && desc.sourceDid) const isBlueskyLabel = desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID + const avi = size === 'lg' ? 16 : 12 - const {outer, avi, text} = useMemo(() => { + return ( + <> + control.open()} + icon={ + isBlueskyLabel || !isLabeler ? ( + + ) : ( + + ) + } + /> + + {!disableDetailsDialog && ( + + )} + + ) +} + +export type LabelBaseProps = { + /** + * The accessibility label for the pill. + */ + label: string + /** + * The visible pill text. Defaults to `label`. Use this when the visible + * text is too terse to serve as the accessibility label, e.g. "+2". + */ + cta?: string + onPress: () => void + disabled?: boolean + noBg?: boolean + icon?: React.ReactNode +} & CommonProps + +export function LabelBase({ + label, + cta = label, + onPress, + disabled, + size = 'sm', + noBg, + icon, +}: LabelBaseProps) { + const t = useTheme() + + const {outer, text} = useMemo(() => { switch (size) { case 'lg': { return { @@ -79,7 +131,6 @@ export function Label({ paddingVertical: 5, }, ], - avi: 16, text: [a.text_sm], } } @@ -94,7 +145,6 @@ export function Label({ paddingVertical: 3, }, ], - avi: 12, text: [a.text_xs], } } @@ -102,52 +152,39 @@ export function Label({ }, [t, size, noBg]) return ( - <> - - - {!disableDetailsDialog && ( - + {cta} + + )} - + ) } diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index fc65bea551..c715dbd3ad 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -321,7 +321,11 @@ export function QuoteEmbed({ linkDisabled /> {moderation ? ( - + ) : null} {richText ? ( void +}) { + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + const {gtMobile} = useBreakpoints() + const [details, setDetails] = useState('') + const {subject} = useLabelSubject({label}) + const isAccountReport = 'did' in subject + const agent = useAgent() + const sourceName = labeler + ? sanitizeHandle(labeler.creator.handle, '@') + : label.src + const [error, setError] = useState(null) + + const {mutate, isPending} = useMutation({ + mutationFn: async () => { + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' + await agent.createModerationReport( + { + reasonType: ToolsOzoneReportDefs.REASONAPPEAL, + subject: { + $type, + ...subject, + }, + reason: details, + }, + { + encoding: 'application/json', + headers: { + 'atproto-proxy': `${label.src}#atproto_labeler`, + }, + }, + ) + }, + onError: err => { + if (err instanceof XRPCError && err.error === 'AlreadyAppealed') { + setError( + _( + msg`You've already appealed this label and it's being reviewed by our moderation team.`, + ), + ) + } else { + setError(_(msg`Failed to submit appeal, please try again.`)) + } + logger.error('Failed to submit label appeal', {message: err}) + }, + onSuccess: () => { + control.close() + Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'}))) + }, + }) + + const onSubmit = () => mutate() + + return ( + <> + + + Appeal "{strings.name}" label + + + + This appeal will be sent to{' '} + control.close()} + style={[a.text_md, a.leading_snug]}> + {sourceName} + + . + + + + {error && ( + + {error} + + )} + + + + + + + + + {IS_ANDROID && } + + ) +} diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index 126e496887..85cb1e4a3a 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -1,9 +1,10 @@ import {type StyleProp, View, type ViewStyle} from 'react-native' -import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api' +import {type ComAtprotoLabelDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Plural} from '@lingui/react/macro' +import {filterUserFacingLabels} from '#/lib/moderation' import {useSession} from '#/state/session' import {atoms as a} from '#/alf' import { @@ -19,12 +20,10 @@ import { } from '#/components/moderation/LabelsOnMeDialog' export function LabelsOnMe({ - type, labels, size, style, }: { - type: 'account' | 'content' labels: ComAtprotoLabelDefs.Label[] | undefined size?: ButtonSize style?: StyleProp @@ -36,18 +35,14 @@ export function LabelsOnMe({ if (!labels || !currentAccount) { return null } - labels = labels.filter( - l => - !l.val.startsWith('!') && - !(l.val === 'bot' && l.src === currentAccount.did), - ) + labels = filterUserFacingLabels(labels, currentAccount.did) if (!labels.length) { return null } return ( - + ) } - -export function LabelsOnMyPost({ - post, - style, -}: { - post: AppBskyFeedDefs.PostView - style?: StyleProp -}) { - const {currentAccount} = useSession() - if (post.author.did !== currentAccount?.did) { - return null - } - return ( - - ) -} diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx index 83fe2e4019..963e4b762a 100644 --- a/src/components/moderation/LabelsOnMeDialog.tsx +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -1,35 +1,34 @@ -import {useCallback, useMemo, useState} from 'react' +import {useMemo, useState} from 'react' import {View} from 'react-native' -import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api' -import {XRPCError} from '@atproto/api' +import {type ComAtprotoLabelDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useMutation} from '@tanstack/react-query' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {useLabelSubject} from '#/lib/moderation' import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeHandle} from '#/lib/strings/handles' -import {logger} from '#/logger' -import {useAgent, useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {InlineLinkText} from '#/components/Link' -import * as Toast from '#/components/Toast' +import {AppealForm} from '#/components/moderation/AppealForm' import {Text} from '#/components/Typography' -import {IS_ANDROID} from '#/env' -import {Admonition} from '../Admonition' import {Divider} from '../Divider' -import {Loader} from '../Loader' export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog' export interface LabelsOnMeDialogProps { control: Dialog.DialogOuterProps['control'] labels: ComAtprotoLabelDefs.Label[] + /** + * Whether the labels are being shown on the user's account or on a post. + * With `content`, the list may include account-level labels, which get a + * per-label callout. + */ type: 'account' | 'content' } @@ -62,7 +61,7 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { label={ isAccount ? _(msg`The following labels were applied to your account.`) - : _(msg`The following labels were applied to your content.`) + : _(msg`The following labels were applied to this post.`) }> {appealingLabel ? ( Labels on your account ) : ( - Labels on your content + Labels applied to this post )} @@ -96,9 +95,10 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { {labels.map(label => ( {!isSelfLabel && ( - - - {IS_ANDROID && } - - ) -} diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx index ddc42effa3..bf19dc0363 100644 --- a/src/components/moderation/ModerationDetailsDialog.tsx +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -1,3 +1,4 @@ +import {useState} from 'react' import {View} from 'react-native' import {type ModerationCause} from '@atproto/api' import {msg} from '@lingui/core/macro' @@ -9,10 +10,12 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause import {makeProfileLink} from '#/lib/routes/links' import {listUriToHref} from '#/lib/strings/url-helpers' import {useSession} from '#/state/session' -import {atoms as a, useGutters, useTheme, web} from '#/alf' +import {atoms as a, useBreakpoints, useGutters, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {InlineLinkText} from '#/components/Link' +import {AppealForm} from '#/components/moderation/AppealForm' import {type AppModerationCause} from '#/components/Pills' import {Text} from '#/components/Typography' import {IS_NATIVE} from '#/env' @@ -47,6 +50,19 @@ function ModerationDetailsDialogInner({ const desc = useModerationCauseDescription(modcause) const {currentAccount} = useSession() const timeDiff = useGetTimeAgo({future: true}) + const [isAppealing, setIsAppealing] = useState(false) + const {gtPhone} = useBreakpoints() + + /* + * Appeal eligibility: only for label causes on content belonging to the + * current user, where the label was not self-applied. + */ + const canAppeal = + modcause?.type === 'label' && + !!currentAccount && + modcause.label.src !== currentAccount.did && + (modcause.label.uri === currentAccount.did || + modcause.label.uri.startsWith(`at://${currentAccount.did}/`)) let name let description @@ -137,6 +153,23 @@ function ModerationDetailsDialogInner({ const sourceName = desc.source || desc.sourceDisplayName || _(msg`an unknown labeler`) + if (isAppealing && modcause?.type === 'label') { + return ( + + setIsAppealing(false)} + /> + + + ) + } + return ( + {canAppeal && ( + + + + You may appeal these labels if you feel they were placed in + error. + + + + + )} + {desc.isSubjectAccount && ( - This moderation was applied to the entire user account and will - appear on all posts. + This label was applied to the entire user account and will appear + on all posts. )} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx index 7a9c38cc61..4379c76cb3 100644 --- a/src/components/moderation/PostAlerts.tsx +++ b/src/components/moderation/PostAlerts.tsx @@ -1,28 +1,92 @@ -import {type StyleProp, type ViewStyle} from 'react-native' -import {type ModerationCause, type ModerationUI} from '@atproto/api' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import { + type AppBskyFeedDefs, + type ComAtprotoLabelDefs, + type ModerationCause, + type ModerationUI, +} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' -import {getModerationCauseKey, unique} from '#/lib/moderation' +import { + filterUserFacingLabels, + getModerationCauseKey, + unique, +} from '#/lib/moderation' +import {useSession} from '#/state/session' +import {atoms as a} from '#/alf' +import { + LabelsOnMeDialog, + useLabelsOnMeDialogControl, +} from '#/components/moderation/LabelsOnMeDialog' import * as Pills from '#/components/Pills' export function PostAlerts({ + post, modui, - size = 'sm', + view = 'compact', style, additionalCauses, }: { + post?: AppBskyFeedDefs.PostView modui: ModerationUI - size?: Pills.CommonProps['size'] + /** + * Expanded views (e.g. the thread anchor post) render larger pills and + * surface the "+n" additional labels pill. Compact views (feeds, replies) + * keep the alerts minimal. + */ + view?: 'expanded' | 'compact' includeMute?: boolean style?: StyleProp additionalCauses?: ModerationCause[] | Pills.AppModerationCause[] }) { - if (!modui.alert && !modui.inform && !additionalCauses?.length) { + const {currentAccount} = useSession() + const size: Pills.CommonProps['size'] = view === 'expanded' ? 'lg' : 'sm' + + const alerts = modui.alerts.filter(unique) + const informs = modui.informs.filter(unique) + /* + * The "+n" pill surfaces labels for the author to review and appeal, so it + * only applies when the viewer is the author, and only in expanded views. + * It renders even when no other moderation is visible, since it may be the + * author's only entry point to appeal labels on their content. + */ + const isOwnPost = !!post && post.author.did === currentAccount?.did + const allLabels: ComAtprotoLabelDefs.Label[] = + isOwnPost && view === 'expanded' + ? [...(post.labels ?? []), ...(post.author.labels ?? [])] + : [] + /* + * Labels that the moderation system already surfaces in this context - + * whether as an alert, an inform, or a blur handled by ContentHider - should + * not be repeated in the "+n" pill. + */ + const shownCauses = [...alerts, ...informs, ...modui.blurs] + const additionalLabels = filterUserFacingLabels( + allLabels, + currentAccount?.did, + ).filter(label => + shownCauses.every( + cause => + cause.type !== 'label' || + cause.label.val !== label.val || + cause.label.src !== label.src || + cause.label.uri !== label.uri, + ), + ) + + if ( + !modui.alert && + !modui.inform && + !additionalCauses?.length && + !additionalLabels.length + ) { return null } return ( - {modui.alerts.filter(unique).map(cause => ( + {alerts.map(cause => ( ))} - {modui.informs.filter(unique).map(cause => ( + {informs.map(cause => ( ))} + {additionalLabels.length ? ( + 0 || + informs.length > 0 || + (additionalCauses?.length ?? 0) > 0 + } + /> + ) : null} ) } + +function AdditionalLabels({ + labels, + size, + hasPrecedingPills, +}: { + labels: ComAtprotoLabelDefs.Label[] + size?: Pills.CommonProps['size'] + /** + * The compact "+n" syntax only makes sense as a continuation of other + * pills. When this pill stands alone, spell it out. + */ + hasPrecedingPills: boolean +}) { + const {t: l} = useLingui() + const control = useLabelsOnMeDialogControl() + + return ( + + + + { + control.open() + }} + /> + + ) +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 2fb37009e2..0d2e3c4dbd 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -58,6 +58,21 @@ export function labelIsHideableOffense( return ['!hide', '!takedown'].includes(label.val) } +/** + * Filters out labels that are not user-facing: system labels (val prefixed + * with `!`) and the user's own "bot" self-label. + */ +export function filterUserFacingLabels( + labels: ComAtprotoLabelDefs.Label[], + currentAccountDid: string | undefined, +): ComAtprotoLabelDefs.Label[] { + return labels.filter( + label => + !label.val.startsWith('!') && + !(label.val === 'bot' && label.src === currentAccountDid), + ) +} + export function getLabelingServiceTitle({ displayName, handle, diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index d8de21209d..0acd0c28e7 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -164,7 +164,6 @@ function DirectChatItem({ isWithinLeftPanel ? null : ( ) diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 4b69e1f67d..477939cd3e 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -240,9 +240,9 @@ function MessageInputPostEmbed({ {rt.text && ( - - - )} - diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index c0ff26206d..9129c4167f 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -39,7 +39,6 @@ import { maybeApplyGalleryOffsetStyles, } from '#/components/images/Gallery' import {ContentHider} from '#/components/moderation/ContentHider' -import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {type AppModerationCause} from '#/components/Pills' import {Embed} from '#/components/Post/Embed' @@ -420,7 +419,6 @@ let FeedItemInner = ({ isParentNotFound={isParentNotFound} /> )} -