Collapse label appeal flow into ModerationDetailsDialog (#11091)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Austin McKinley
2026-07-10 16:26:10 -07:00
committed by GitHub
parent fa27d446d8
commit b4dfa3cc22
16 changed files with 511 additions and 265 deletions
+83 -46
View File
@@ -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 (
<>
<LabelBase
label={desc.name}
size={size}
noBg={noBg}
disabled={disableDetailsDialog}
onPress={() => control.open()}
icon={
isBlueskyLabel || !isLabeler ? (
<desc.icon width={avi} fill={t.atoms.text_contrast_medium.color} />
) : (
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
)
}
/>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}
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 (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
<Button
disabled={disabled}
label={label}
onPress={e => {
e.preventDefault()
e.stopPropagation()
onPress()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
outer,
(hovered || pressed) && t.atoms.bg_contrast_50,
]}>
{icon}
<Text
emoji
style={[
a.flex_row,
a.align_center,
a.rounded_full,
outer,
(hovered || pressed) && t.atoms.bg_contrast_50,
text,
a.font_semi_bold,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingRight: 3},
]}>
{isBlueskyLabel || !isLabeler ? (
<desc.icon
width={avi}
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} type="user" size={avi} />
)}
<Text
emoji
style={[
text,
a.font_semi_bold,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingRight: 3},
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
{cta}
</Text>
</View>
)}
</>
</Button>
)
}
+5 -1
View File
@@ -321,7 +321,11 @@ export function QuoteEmbed({
linkDisabled
/>
{moderation ? (
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
<PostAlerts
post={quote}
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
+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}} />}
</>
)
}
+9 -38
View File
@@ -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<ViewStyle>
@@ -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 (
<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 +54,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} />
)
}
+31 -155
View File
@@ -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 ? (
<AppealForm
@@ -76,7 +75,7 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
{isAccount ? (
<Trans>Labels on your account</Trans>
) : (
<Trans>Labels on your content</Trans>
<Trans>Labels applied to this post</Trans>
)}
</Text>
<Text style={[a.text_md, a.leading_snug]}>
@@ -96,9 +95,10 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
<View style={[a.py_lg, a.gap_md]}>
{labels.map(label => (
<Label
key={`${label.val}-${label.src}`}
key={`${label.val}-${label.src}-${label.uri}`}
label={label}
isSelfLabel={label.src === currentAccount?.did}
showAccountCallout={!isAccount && label.uri.startsWith('did:')}
control={props.control}
onPressAppeal={setAppealingLabel}
/>
@@ -114,11 +114,17 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
function Label({
label,
isSelfLabel,
showAccountCallout,
control,
onPressAppeal,
}: {
label: ComAtprotoLabelDefs.Label
isSelfLabel: boolean
/**
* Call out that the label applies to the whole account, for contexts that
* mix account and content labels.
*/
showAccountCallout?: boolean
control: Dialog.DialogOuterProps['control']
onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
}) {
@@ -145,12 +151,20 @@ function Label({
<Text emoji style={[t.atoms.text_contrast_medium, a.leading_snug]}>
{strings.description}
</Text>
{showAccountCallout && (
<Admonition type="info" style={[a.mt_sm]}>
<Trans>
This label was applied to the entire user account and will
appear on all posts.
</Trans>
</Admonition>
)}
</View>
{!isSelfLabel && (
<View>
<Button
variant="solid"
color="secondary"
color="primary_subtle"
size="small"
label={_(msg`Appeal`)}
onPress={() => onPressAppeal(label)}>
@@ -211,141 +225,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'
@@ -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 (
<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`)}
@@ -156,11 +189,48 @@ function ModerationDetailsDialogInner({
{description}
</Text>
{canAppeal && (
<View
style={[
a.flex_row,
a.flex_wrap,
a.gap_sm,
a.pt_md,
a.pb_xs,
a.mt_md,
a.border_t,
t.atoms.border_contrast_low,
]}>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
gtPhone ? a.flex_1 : a.w_full,
]}>
<Trans>
You may appeal these labels if you feel they were placed in
error.
</Trans>
</Text>
<Button
variant="solid"
color="primary_subtle"
size="small"
label={_(msg`Appeal this label`)}
style={[gtPhone ? undefined : a.w_full]}
onPress={() => setIsAppealing(true)}>
<ButtonText>
<Trans>Appeal</Trans>
</ButtonText>
</Button>
</View>
)}
{desc.isSubjectAccount && (
<Admonition type="info" style={[a.mt_md]}>
<Trans>
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.
</Trans>
</Admonition>
)}
+126 -8
View File
@@ -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<ViewStyle>
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 (
<Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}>
{modui.alerts.filter(unique).map(cause => (
{alerts.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
@@ -30,7 +94,7 @@ export function PostAlerts({
noBg={size === 'sm'}
/>
))}
{modui.informs.filter(unique).map(cause => (
{informs.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
@@ -46,6 +110,60 @@ export function PostAlerts({
noBg={size === 'sm'}
/>
))}
{additionalLabels.length ? (
<AdditionalLabels
labels={additionalLabels}
size={size}
hasPrecedingPills={
alerts.length > 0 ||
informs.length > 0 ||
(additionalCauses?.length ?? 0) > 0
}
/>
) : null}
</Pills.Row>
)
}
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 (
<View style={[a.flex_row]}>
<LabelsOnMeDialog control={control} labels={labels} type="content" />
<Pills.LabelBase
label={l`${plural(labels.length, {
one: '# label applied to your post',
other: '# labels applied to your post',
})}`}
cta={
hasPrecedingPills
? l`+${labels.length}`
: l`${plural(labels.length, {
one: '# label applied',
other: '# labels applied',
})}`
}
size={size}
noBg={size === 'sm'}
onPress={() => {
control.open()
}}
/>
</View>
)
}
+15
View File
@@ -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,
@@ -164,7 +164,6 @@ function DirectChatItem({
isWithinLeftPanel ? null : (
<PostAlerts
modui={moderation.ui('contentList')}
size="sm"
style={[a.pb_2xs, a.max_w_full, a.overflow_hidden]}
/>
)
@@ -240,9 +240,9 @@ function MessageInputPostEmbed({
</View>
<ContentHider modui={moderation.ui('contentView')}>
<PostAlerts
post={post}
modui={moderation.ui('contentView')}
style={a.py_xs}
size="sm"
/>
{rt.text && (
<RichText
@@ -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,14 +383,14 @@ 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
childContainerStyle={[a.pt_sm]}>
<PostAlerts
post={post}
modui={moderation.ui('contentView')}
size="lg"
view="expanded"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
@@ -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,8 +309,8 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
}),
]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
post={post}
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
@@ -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,8 +339,8 @@ 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
post={post}
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
-1
View File
@@ -228,7 +228,6 @@ let ProfileHeaderShell = ({
{!isPlaceholderProfile &&
(isMe ? (
<LabelsOnMe
type="account"
labels={profile.labels}
style={[
a.px_lg,
+1 -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,12 +214,12 @@ function PostInner({
{replyAuthorDid !== '' && (
<PostRepliedTo parentAuthor={replyAuthorDid} />
)}
<LabelsOnMyPost post={post} />
<ContentHider
modui={moderation.ui('contentView')}
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
post={post}
modui={moderation.ui('contentView')}
style={[a.pb_xs]}
/>
+1 -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}
@@ -495,6 +493,7 @@ let PostContent = ({
ignoreMute
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
post={post}
modui={moderation.ui('contentList')}
style={[a.pb_xs]}
additionalCauses={additionalPostAlerts}