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:
+83
-46
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -228,7 +228,6 @@ let ProfileHeaderShell = ({
|
||||
{!isPlaceholderProfile &&
|
||||
(isMe ? (
|
||||
<LabelsOnMe
|
||||
type="account"
|
||||
labels={profile.labels}
|
||||
style={[
|
||||
a.px_lg,
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user