Collapse label appeal flow into ModerationDetailsDialog

Labels on your own posts previously rendered twice: once as normal
label chips and again as an ominous "1 content label" pill whose only
purpose was reaching the appeal dialog. Move the Appeal button into
ModerationDetailsDialog (shown only when the label targets your own
account or content and was not self-applied), extract AppealForm into
its own module, and remove the redundant per-post pill. The
account-level pill on the profile header is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Austin McKinley
2026-07-07 13:03:33 -07:00
parent ea3d6827c7
commit 56fa8aca31
10 changed files with 221 additions and 195 deletions
+162
View File
@@ -0,0 +1,162 @@
import {useState} from 'react'
import {View} from 'react-native'
import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api'
import {XRPCError} 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 {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} from '#/state/session'
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_ANDROID} from '#/env'
export function AppealForm({
label,
control,
onPressBack,
}: {
label: ComAtprotoLabelDefs.Label
control: Dialog.DialogOuterProps['control']
onPressBack: () => 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<string | null>(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 (
<>
<View>
<Text style={[a.text_2xl, a.font_semi_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
.
</Trans>
</Text>
</View>
{error && (
<Admonition type="error" style={[a.mt_sm]}>
{error}
</Admonition>
)}
<View style={[a.my_md]}>
<Dialog.Input
label={_(msg`Text input field`)}
placeholder={_(
msg`Please explain why you think this label was incorrectly applied by ${
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
}`,
)}
value={details}
onChangeText={setDetails}
autoFocus={true}
numberOfLines={3}
multiline
maxLength={300}
/>
</View>
<View
style={
gtMobile
? [a.flex_row, a.justify_between]
: [{flexDirection: 'column-reverse'}, a.gap_sm]
}>
<Button
testID="backBtn"
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}
label={_(msg`Back`)}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<Button
testID="submitBtn"
variant="solid"
color="primary"
size="large"
onPress={onSubmit}
label={_(msg`Submit`)}>
<ButtonText>{_(msg`Submit`)}</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
{IS_ANDROID && <View style={{height: 300}} />}
</>
)
}
+7 -33
View File
@@ -1,5 +1,5 @@
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'
@@ -19,12 +19,10 @@ import {
} from '#/components/moderation/LabelsOnMeDialog'
export function LabelsOnMe({
type,
labels,
size,
style,
}: {
type: 'account' | 'content'
labels: ComAtprotoLabelDefs.Label[] | undefined
size?: ButtonSize
style?: StyleProp<ViewStyle>
@@ -47,7 +45,7 @@ export function LabelsOnMe({
return (
<View style={[a.flex_row, style]}>
<LabelsOnMeDialog control={control} labels={labels} type={type} />
<LabelsOnMeDialog control={control} labels={labels} type="account" />
<Button
variant="solid"
@@ -59,37 +57,13 @@ export function LabelsOnMe({
}}>
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{type === 'account' ? (
<Plural
value={labels.length}
one="# account label"
other="# account labels"
/>
) : (
<Plural
value={labels.length}
one="# content label"
other="# content labels"
/>
)}
<Plural
value={labels.length}
one="# account label"
other="# account labels"
/>
</ButtonText>
</Button>
</View>
)
}
export function LabelsOnMyPost({
post,
style,
}: {
post: AppBskyFeedDefs.PostView
style?: StyleProp<ViewStyle>
}) {
const {currentAccount} = useSession()
if (post.author.did !== currentAccount?.did) {
return null
}
return (
<LabelsOnMe type="content" labels={post.labels} size="tiny" style={style} />
)
}
+6 -151
View File
@@ -1,29 +1,22 @@
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 {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'
@@ -211,141 +204,3 @@ function Label({
</View>
)
}
function AppealForm({
label,
control,
onPressBack,
}: {
label: ComAtprotoLabelDefs.Label
control: Dialog.DialogOuterProps['control']
onPressBack: () => 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<string | null>(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 = useCallback(() => mutate(), [mutate])
return (
<>
<View>
<Text style={[a.text_2xl, a.font_semi_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
.
</Trans>
</Text>
</View>
{error && (
<Admonition type="error" style={[a.mt_sm]}>
{error}
</Admonition>
)}
<View style={[a.my_md]}>
<Dialog.Input
label={_(msg`Text input field`)}
placeholder={_(
msg`Please explain why you think this label was incorrectly applied by ${
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
}`,
)}
value={details}
onChangeText={setDetails}
autoFocus={true}
numberOfLines={3}
multiline
maxLength={300}
/>
</View>
<View
style={
gtMobile
? [a.flex_row, a.justify_between]
: [{flexDirection: 'column-reverse'}, a.gap_sm]
}>
<Button
testID="backBtn"
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}
label={_(msg`Back`)}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<Button
testID="submitBtn"
variant="solid"
color="primary"
size="large"
onPress={onSubmit}
label={_(msg`Submit`)}>
<ButtonText>{_(msg`Submit`)}</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
{IS_ANDROID && <View style={{height: 300}} />}
</>
)
}
@@ -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'
@@ -11,8 +12,10 @@ import {listUriToHref} from '#/lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {atoms as a, 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,18 @@ function ModerationDetailsDialogInner({
const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession()
const timeDiff = useGetTimeAgo({future: true})
const [isAppealing, setIsAppealing] = useState(false)
/*
* 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 +152,23 @@ function ModerationDetailsDialogInner({
const sourceName =
desc.source || desc.sourceDisplayName || _(msg`an unknown labeler`)
if (isAppealing && modcause?.type === 'label') {
return (
<Dialog.ScrollableInner
label={_(msg`Appeal label`)}
style={web({
maxWidth: 460,
})}>
<AppealForm
label={modcause.label}
control={control}
onPressBack={() => setIsAppealing(false)}
/>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
return (
<Dialog.ScrollableInner
label={_(msg`Moderation details`)}
@@ -228,6 +260,20 @@ function ModerationDetailsDialogInner({
</View>
)}
</View>
{canAppeal && (
<View style={[a.flex_row, a.justify_end, a.mt_sm]}>
<Button
variant="solid"
color="secondary"
size="small"
label={_(msg`Appeal this label`)}
onPress={() => setIsAppealing(true)}>
<ButtonText>
<Trans>Appeal</Trans>
</ButtonText>
</Button>
</View>
)}
</>
)}
</View>
@@ -42,7 +42,6 @@ import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Tra
import {GalleryBleed} from '#/components/images/Gallery'
import {Link} from '#/components/Link'
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, PostEmbedViewContext} from '#/components/Post/Embed'
@@ -384,7 +383,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</View>
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
@@ -36,7 +36,6 @@ import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
@@ -310,7 +309,6 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
}),
]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
@@ -33,7 +33,6 @@ import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
@@ -340,7 +339,6 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
<View style={[a.flex_row]}>
<ThreadItemTreeReplyChildReplyLine item={item} />
<View style={[a.flex_1, a.pl_2xs]}>
<LabelsOnMyPost post={post} style={[a.pb_2xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
-1
View File
@@ -228,7 +228,6 @@ let ProfileHeaderShell = ({
{!isPlaceholderProfile &&
(isMe ? (
<LabelsOnMe
type="account"
labels={profile.labels}
style={[
a.px_lg,
-2
View File
@@ -30,7 +30,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 {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
@@ -215,7 +214,6 @@ function PostInner({
{replyAuthorDid !== '' && (
<PostRepliedTo parentAuthor={replyAuthorDid} />
)}
<LabelsOnMyPost post={post} />
<ContentHider
modui={moderation.ui('contentView')}
style={styles.contentHider}
-2
View File
@@ -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}
/>
)}
<LabelsOnMyPost post={post} />
<PostContent
moderation={moderation}
richText={richText}