Copy over fresh report dialog based on old

This commit is contained in:
Eric Bailey
2025-02-23 17:38:09 -06:00
parent ba5d9ed510
commit 733f8eb8fc
8 changed files with 1147 additions and 0 deletions
@@ -0,0 +1,99 @@
import {
$Typed,
ChatBskyConvoDefs,
ComAtprotoModerationCreateReport,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {ReportState} from './state'
import {ParsedReportSubject} from './types'
export function useSubmitReportMutation() {
const {_} = useLingui()
const agent = useAgent()
return useMutation({
async mutationFn({
subject,
state,
}: {
subject: ParsedReportSubject
state: ReportState
}) {
if (!state.selectedOption) {
throw new Error(_(msg`Please select a reason for this report`))
}
if (!state.selectedLabeler) {
throw new Error(_(msg`Please select a moderation service`))
}
let report:
| ComAtprotoModerationCreateReport.InputSchema
| (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & {
subject: $Typed<ChatBskyConvoDefs.MessageRef>
})
switch (subject.type) {
case 'account': {
report = {
reasonType: state.selectedOption.reason,
reason: state.details,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: subject.did,
},
}
break
}
case 'post':
case 'list':
case 'feed':
case 'starterPack': {
report = {
reasonType: state.selectedOption.reason,
reason: state.details,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: subject.uri,
cid: subject.cid,
},
}
break
}
case 'chatMessage': {
report = {
reasonType: state.selectedOption.reason,
reason: state.details,
subject: {
$type: 'chat.bsky.convo.defs#messageRef',
messageId: subject.message.id,
convoId: subject.convoId,
did: subject.message.sender.did,
},
}
break
}
}
if (__DEV__) {
logger.info('Submitting report', {
labeler: {
handle: state.selectedLabeler.creator.handle,
},
report,
})
} else {
await agent.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${state.selectedLabeler.creator.did}#atproto_labeler`,
},
})
}
},
})
}
@@ -0,0 +1,98 @@
import {ComAtprotoModerationDefs} from '@atproto/api'
import {ParsedReportSubject} from './types'
export const ALL_REPORT_REASON_TYPES = [
'com.atproto.moderation.defs#reasonSpam',
'com.atproto.moderation.defs#reasonViolation',
'com.atproto.moderation.defs#reasonMisleading',
'com.atproto.moderation.defs#reasonSexual',
'com.atproto.moderation.defs#reasonRude',
'com.atproto.moderation.defs#reasonHarassment',
'com.atproto.moderation.defs#reasonAnimalAbuse',
'com.atproto.moderation.defs#reasonGraphic',
'com.atproto.moderation.defs#reasonScam',
'com.atproto.moderation.defs#reasonProhibitedCommerce',
'com.atproto.moderation.defs#reasonInauthentic',
'com.atproto.moderation.defs#reasonImpersonation',
'com.atproto.moderation.defs#reasonBanEvasion',
'com.atproto.moderation.defs#reasonChildSafety',
'com.atproto.moderation.defs#reasonSelfHarm',
'com.atproto.moderation.defs#reasonThreat',
'com.atproto.moderation.defs#reasonIntolerance',
'com.atproto.moderation.defs#reasonPrivateInfo',
'com.atproto.moderation.defs#reasonSexualAbuse',
'com.atproto.moderation.defs#reasonSiteSecurity',
'com.atproto.moderation.defs#reasonTerrorViolentExtremist',
'com.atproto.moderation.defs#reasonSpoiler',
'com.atproto.moderation.defs#reasonGrowthHack',
'com.atproto.moderation.defs#reasonOther',
]
const REASONHARRASSMENT = 'com.atproto.moderation.defs#reasonHarassment'
const REASONINAUTHENTIC = 'com.atproto.moderation.defs#reasonInauthentic'
const REASONIMPERSONATION = 'com.atproto.moderation.defs#reasonImpersonation'
const REASONBANEVASION = 'com.atproto.moderation.defs#reasonBanEvasion'
const REASONTERRORVIOLENTEXTREMIST =
'com.atproto.moderation.defs#reasonTerrorViolentExtremist'
const REASONANIMALABUSE = 'com.atproto.moderation.defs#reasonAnimalAbuse'
const REASONGRAPHIC = 'com.atproto.moderation.defs#reasonGraphic'
const REASONSCAM = 'com.atproto.moderation.defs#reasonScam'
const REASONPROHIBITEDCOMMERCE =
'com.atproto.moderation.defs#reasonProhibitedCommerce'
const REASONCHILDSAFETY = 'com.atproto.moderation.defs#reasonChildSafety'
const REASONSELFHARM = 'com.atproto.moderation.defs#reasonSelfHarm'
const REASONTHREAT = 'com.atproto.moderation.defs#reasonThreat'
const REASONINTOLERANCE = 'com.atproto.moderation.defs#reasonIntolerance'
const REASONPRIVATEINFO = 'com.atproto.moderation.defs#reasonPrivateInfo'
const REASONSEXUALABUSE = 'com.atproto.moderation.defs#reasonSexualAbuse'
const REASONSITESECURITY = 'com.atproto.moderation.defs#reasonSiteSecurity'
const RECORD_REPORT_OPTIONS = [
ComAtprotoModerationDefs.REASONSPAM,
REASONHARRASSMENT,
ComAtprotoModerationDefs.REASONMISLEADING,
ComAtprotoModerationDefs.REASONSEXUAL,
REASONANIMALABUSE,
REASONGRAPHIC,
REASONSCAM,
REASONPROHIBITEDCOMMERCE,
REASONCHILDSAFETY,
REASONSELFHARM,
REASONTHREAT,
REASONINTOLERANCE,
REASONPRIVATEINFO,
REASONSEXUALABUSE,
REASONSITESECURITY,
REASONTERRORVIOLENTEXTREMIST,
ComAtprotoModerationDefs.REASONOTHER,
]
export const REPORT_OPTIONS: Record<ParsedReportSubject['type'], string[]> = {
account: [
REASONHARRASSMENT,
REASONINAUTHENTIC,
REASONIMPERSONATION,
REASONBANEVASION,
REASONTERRORVIOLENTEXTREMIST,
ComAtprotoModerationDefs.REASONSPAM,
ComAtprotoModerationDefs.REASONMISLEADING,
ComAtprotoModerationDefs.REASONOTHER,
],
post: RECORD_REPORT_OPTIONS,
list: RECORD_REPORT_OPTIONS,
feed: RECORD_REPORT_OPTIONS,
starterPack: RECORD_REPORT_OPTIONS,
chatMessage: [
ComAtprotoModerationDefs.REASONSPAM,
REASONHARRASSMENT,
ComAtprotoModerationDefs.REASONSEXUAL,
REASONSCAM,
REASONCHILDSAFETY,
REASONSELFHARM,
REASONTHREAT,
REASONPRIVATEINFO,
ComAtprotoModerationDefs.REASONOTHER,
],
}
@@ -0,0 +1,49 @@
import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ParsedReportSubject} from './types'
export function useCopyForSubject(subject: ParsedReportSubject) {
const {_} = useLingui()
return useMemo(() => {
switch (subject.type) {
case 'account': {
return {
title: _(msg`Report this user`),
subtitle: _(msg`Why should this user be reviewed?`),
}
}
case 'post': {
return {
title: _(msg`Report this post`),
subtitle: _(msg`Why should this post be reviewed?`),
}
}
case 'list': {
return {
title: _(msg`Report this list`),
subtitle: _(msg`Why should this list be reviewed?`),
}
}
case 'feed': {
return {
title: _(msg`Report this feed`),
subtitle: _(msg`Why should this feed be reviewed?`),
}
}
case 'starterPack': {
return {
title: _(msg`Report this starter pack`),
subtitle: _(msg`Why should this starter pack be reviewed?`),
}
}
case 'chatMessage': {
return {
title: _(msg`Report this message`),
subtitle: _(msg`Why should this message be reviewed?`),
}
}
}
}, [_, subject])
}
@@ -0,0 +1,518 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {wait} from '#/lib/async/wait'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useMyLabelersQuery} from '#/state/queries/preferences'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useGutters,useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon,ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {
Check_Stroke2_Corner0_Rounded as CheckThin,
CheckThick_Stroke2_Corner0_Rounded as Check,
} from '#/components/icons/Check'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createStaticClick,InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useSubmitReportMutation} from './action'
import {useCopyForSubject} from './copy'
import {initialState, reducer} from './state'
import {ReportDialogProps, ReportSubject} from './types'
import {parseReportSubject} from './utils/parseReportSubject'
import {ReportOption,useReportOptions} from './utils/useReportOptions'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
export function ReportDialog(
props: Omit<ReportDialogProps, 'subject'> & {
subject: ReportSubject
},
) {
const subject = React.useMemo(
() => parseReportSubject(props.subject),
[props.subject],
)
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
{subject ? <Inner {...props} subject={subject} /> : <Invalid />}
</Dialog.Outer>
)
}
// TODO
function Invalid() {
const {_} = useLingui()
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)}>
<Text style={[a.font_heavy, a.text_xl]}>
<Trans>Invalid report subject</Trans>
</Text>
<Text style={[a.text_md]}>
<Trans>This is a developer error.</Trans>
</Text>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Inner(props: ReportDialogProps) {
const {_} = useLingui()
const ref = React.useRef<ScrollView>(null)
const {
isLoading: isLabelerLoading,
data: allLabelers,
error: labelersLoadError,
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
const isLoading = useDelayedLoading(500, isLabelerLoading)
const copy = useCopyForSubject(props.subject)
const allOptions = useReportOptions()
const options = allOptions[props.subject.type]
const [state, dispatch] = React.useReducer(reducer, initialState)
/**
* Labelers that support this `subject` and its NSID collection
*/
const supportedLabelers = React.useMemo(() => {
if (!allLabelers) return []
return allLabelers
.filter(l => {
// @ts-ignore
const subjectTypes: string[] | undefined = l.subjectTypes
if (subjectTypes === undefined) return true
if (props.subject.type === 'account') {
return subjectTypes.includes('account')
} else if (props.subject.type === 'chatMessage') {
return subjectTypes.includes('chat')
} else {
return subjectTypes.includes('record')
}
})
.filter(l => {
// @ts-ignore
const collections: string[] | undefined = l.subjectCollections
if (collections === undefined) return true
// all chat collections accepted, since only Bluesky handles chats
if (props.subject.type === 'chatMessage') return true
return collections.includes(props.subject.nsid)
})
.filter(l => {
if (!state.selectedOption) return true
// @ts-ignore
const reasonTypes: string[] | undefined = l.reasonTypes
if (reasonTypes === undefined) return true
return reasonTypes.includes(state.selectedOption.reason)
})
}, [props, allLabelers, state.selectedOption])
const {mutateAsync: submitReport} = useSubmitReportMutation()
const [isPending, setPending] = React.useState(false)
const [isSuccess, setSuccess] = React.useState(false)
const onSubmit = React.useCallback(async () => {
dispatch({type: 'clearError'})
try {
setPending(true)
await wait(
1e3,
submitReport({
subject: props.subject,
state,
}),
)
setSuccess(true)
setTimeout(() => {
props.control.close()
}, 1e3)
} catch (e: any) {
logger.error(e, {
source: 'ReportDialog',
})
dispatch({
type: 'setError',
error: _(msg`Something went wrong. Please try again.`),
})
} finally {
setPending(false)
}
}, [_, submitReport, state, dispatch, props, setPending, setSuccess])
return (
<Dialog.ScrollableInner
label={_(msg`Report dialog`)}
ref={ref}
style={[a.w_full, {maxWidth: 500}]}>
<View style={[a.gap_2xl, isNative && a.pt_md]}>
<StepOuter>
<StepTitle
index={1}
title={copy.subtitle}
activeIndex1={state.activeStepIndex1}
/>
{isLoading ? (
<View style={[a.align_center, {height: 100}]}>
<Loader size="xl" />
{/* Here to capture focus for a hot sec to prevent flash */}
<Pressable accessible={false} />
</View>
) : labelersLoadError || !allLabelers ? (
<View>
<Text style={[a.text_md]}>
<Trans>Something went wrong, please try again.</Trans>
</Text>
</View>
) : (
<>
{state.selectedOption ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
</View>
<Button
label={_(msg`Change report reason`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : (
<View style={[a.gap_sm]}>
{options.map(o => (
<OptionCard
key={o.reason}
option={o}
onSelect={() => {
dispatch({type: 'selectOption', option: o})
}}
/>
))}
</View>
)}
</>
)}
</StepOuter>
<StepOuter>
<StepTitle
index={2}
title={_(msg`Select moderation service`)}
activeIndex1={state.activeStepIndex1}
/>
{state.activeStepIndex1 >= 2 && (
<>
{state.selectedLabeler ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<LabelerCard labeler={state.selectedLabeler} />
</View>
<Button
label={_(msg`Change report reason`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearLabeler'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : (
<>
{supportedLabelers.length ? (
<View style={[a.gap_sm]}>
{supportedLabelers.map(l => (
<LabelerCard
// @ts-expect-error TODO
key={l.creator.did}
// @ts-expect-error TODO
labeler={l}
onSelect={() => {
// @ts-expect-error TODO
dispatch({type: 'selectLabeler', labeler: l})
}}
/>
))}
</View>
) : (
<Text>TODO</Text>
)}
</>
)}
</>
)}
</StepOuter>
<StepOuter>
<StepTitle
index={3}
title={_(msg`Submit report`)}
activeIndex1={state.activeStepIndex1}
/>
{state.activeStepIndex1 === 3 && (
<>
<View style={[a.pb_xs, a.gap_xs]}>
<Text style={[a.leading_snug, a.pb_xs]}>
<Trans>
Your report will be sent to{' '}
<Text style={[a.font_bold, a.leading_snug]}>
{state.selectedLabeler?.creator.displayName}
</Text>
.{' '}
{!state.detailsOpen ? (
<InlineLinkText
label={_(msg`Add more details`)}
{...createStaticClick(() => {
dispatch({type: 'showDetails'})
})}>
Add more details (optional)
</InlineLinkText>
) : null}
</Trans>
</Text>
{state.detailsOpen && (
<View>
<Dialog.Input
multiline
value={state.details}
onChangeText={details => {
dispatch({type: 'setDetails', details})
}}
label={_(msg`Additional details`)}
style={{paddingRight: 60}}
numberOfLines={4}
/>
<View
style={[
a.absolute,
a.flex_row,
a.align_center,
a.pr_md,
a.pb_sm,
{
bottom: 0,
right: 0,
},
]}>
<CharProgress count={state.details?.length || 0} />
</View>
</View>
)}
</View>
<Button
label={_(msg`Submit report`)}
size="large"
variant="solid"
color="primary"
disabled={isPending || isSuccess}
onPress={onSubmit}>
<ButtonText>
<Trans>Submit report</Trans>
</ButtonText>
<ButtonIcon
icon={isSuccess ? CheckThin : isPending ? Loader : PaperPlane}
/>
</Button>
{state.error && (
<Admonition type="error">{state.error}</Admonition>
)}
</>
)}
</StepOuter>
</View>
</Dialog.ScrollableInner>
)
}
function StepOuter({children}: {children: React.ReactNode}) {
return <View style={[a.gap_md, a.w_full]}>{children}</View>
}
function StepTitle({
index,
title,
activeIndex1,
}: {
index: number
title: string
activeIndex1: number
}) {
const t = useTheme()
const active = activeIndex1 === index
const completed = activeIndex1 > index
return (
<View style={[a.flex_row, a.gap_sm]}>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
completed
? [t.atoms.bg_contrast_500]
: [active ? t.atoms.bg_contrast_900 : t.atoms.bg_contrast_300],
{
width: 24,
height: 24,
},
]}>
{completed ? (
<Check width={12} fill="white" />
) : (
<Text
style={[
a.font_heavy,
a.text_center,
t.atoms.text_inverted,
{
opacity: active ? 1 : 0.6,
},
]}>
{index}
</Text>
)}
</View>
<Text
style={[
a.flex_1,
a.font_heavy,
a.text_lg,
a.leading_snug,
active ? t.atoms.text : t.atoms.text_contrast_medium,
{
top: 1,
},
]}>
{title}
</Text>
</View>
)
}
function OptionCard({
option,
onSelect,
}: {
option: ReportOption
onSelect?: (option: ReportOption) => void
}) {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters(['compact'])
const onPress = React.useCallback(() => {
onSelect?.(option)
}, [onSelect, option])
return (
<Button
label={_(msg`Create report for ${option.title}`)}
onPress={onPress}
disabled={!onSelect}>
{({hovered, pressed}) => (
<View
style={[
a.w_full,
gutters,
a.py_sm,
a.rounded_sm,
a.border,
t.atoms.bg_contrast_25,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
{option.title}
</Text>
<Text
style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}>
{option.description}
</Text>
</View>
)}
</Button>
)
}
function LabelerCard({
labeler,
onSelect,
}: {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void
}) {
const t = useTheme()
const {_} = useLingui()
const onPress = React.useCallback(() => {
onSelect?.(labeler)
}, [onSelect, labeler])
const title = getLabelingServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})
return (
<Button
label={_(msg`Send report to ${title}`)}
onPress={onPress}
disabled={!onSelect}>
{({hovered, pressed}) => (
<View
style={[
a.w_full,
a.p_sm,
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<UserAvatar
type="labeler"
size={36}
avatar={labeler.creator.avatar}
/>
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
{title}
</Text>
<Text
style={[
a.text_sm,
,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans>
</Text>
</View>
</View>
)}
</Button>
)
}
@@ -0,0 +1,102 @@
import {AppBskyLabelerDefs} from '@atproto/api'
import {ReportOption} from './utils/useReportOptions'
export type ReportState = {
selectedOption?: ReportOption
selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed
details?: string
detailsOpen: boolean
activeStepIndex1: number
error?: string
}
export type ReportAction =
| {
type: 'selectOption'
option: ReportOption
}
| {
type: 'clearOption'
}
| {
type: 'selectLabeler'
labeler: AppBskyLabelerDefs.LabelerViewDetailed
}
| {
type: 'clearLabeler'
}
| {
type: 'setDetails'
details: string
}
| {
type: 'setError'
error: string
}
| {
type: 'clearError'
}
| {
type: 'showDetails'
}
export const initialState: ReportState = {
selectedOption: undefined,
selectedLabeler: undefined,
details: undefined,
detailsOpen: false,
activeStepIndex1: 1,
}
export function reducer(state: ReportState, action: ReportAction): ReportState {
switch (action.type) {
case 'selectOption':
return {
...state,
selectedOption: action.option,
activeStepIndex1: 2,
}
case 'clearOption':
return {
...state,
selectedOption: undefined,
selectedLabeler: undefined,
activeStepIndex1: 1,
detailsOpen: !!state.details,
}
case 'selectLabeler':
return {
...state,
selectedLabeler: action.labeler,
activeStepIndex1: 3,
}
case 'clearLabeler':
return {
...state,
selectedLabeler: undefined,
activeStepIndex1: 2,
detailsOpen: !!state.details,
}
case 'setDetails':
return {
...state,
details: action.details,
}
case 'setError':
return {
...state,
error: action.error,
}
case 'clearError':
return {
...state,
error: undefined,
}
case 'showDetails':
return {
...state,
detailsOpen: true,
}
}
}
@@ -0,0 +1,69 @@
import {
$Typed,
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyGraphDefs,
ChatBskyConvoDefs,
} from '@atproto/api'
import * as Dialog from '#/components/Dialog'
export type ReportSubject =
| $Typed<AppBskyActorDefs.ProfileViewBasic>
| $Typed<AppBskyActorDefs.ProfileView>
| $Typed<AppBskyActorDefs.ProfileViewDetailed>
| $Typed<AppBskyGraphDefs.ListView>
| $Typed<AppBskyFeedDefs.GeneratorView>
| $Typed<AppBskyGraphDefs.StarterPackView>
| $Typed<AppBskyFeedDefs.PostView>
| {convoId: string; message: ChatBskyConvoDefs.MessageView}
export type ParsedReportSubject =
| {
type: 'post'
uri: string
cid: string
nsid: string
attributes: {
reply: boolean
image: boolean
video: boolean
link: boolean
quote: boolean
}
}
| {
type: 'list'
uri: string
cid: string
nsid: string
}
| {
type: 'feed'
uri: string
cid: string
nsid: string
}
| {
type: 'starterPack'
uri: string
cid: string
nsid: string
}
| {
type: 'account'
did: string
nsid: string
}
| {
type: 'chatMessage'
convoId: string
message: ChatBskyConvoDefs.MessageView
}
export type ReportDialogProps = {
control: Dialog.DialogOuterProps['control']
subject: ParsedReportSubject
// TODO handle convo report navigation
onFinishReport?: () => void
}
@@ -0,0 +1,91 @@
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
} from '@atproto/api'
import {
ParsedReportSubject,
ReportSubject,
} from '#/components/moderation/ReportDialog/types'
import * as bsky from '#/types/bsky'
export function parseReportSubject(
subject: ReportSubject,
): ParsedReportSubject | undefined {
if (!subject) return
if ('convoId' in subject) {
return {
type: 'chatMessage',
...subject,
}
}
if (
AppBskyActorDefs.isProfileViewBasic(subject) ||
AppBskyActorDefs.isProfileView(subject) ||
AppBskyActorDefs.isProfileViewDetailed(subject)
) {
return {
type: 'account',
did: subject.did,
nsid: 'app.bsky.actor.profile',
}
} else if (AppBskyGraphDefs.isListView(subject)) {
return {
type: 'list',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.graph.list',
}
} else if (AppBskyFeedDefs.isGeneratorView(subject)) {
return {
type: 'feed',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.feed.generator',
}
} else if (AppBskyGraphDefs.isStarterPackView(subject)) {
return {
type: 'starterPack',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.graph.starterPack',
}
} else if (AppBskyFeedDefs.isPostView(subject)) {
const record = subject.record
const embed = bsky.post.parseEmbed(subject.embed)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
record,
AppBskyFeedPost.isRecord,
)
) {
return {
type: 'post',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.feed.post',
attributes: {
reply: !!record.reply,
image:
embed.type === 'images' ||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
video:
embed.type === 'video' ||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
link:
embed.type === 'link' ||
(embed.type === 'post_with_media' && embed.media.type === 'link'),
quote:
embed.type === 'post' ||
(embed.type === 'post_with_media' &&
(embed.view.type === 'post' ||
embed.view.type === 'post_with_media')),
},
}
}
}
}
@@ -0,0 +1,121 @@
import {useMemo} from 'react'
import {ComAtprotoModerationDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export interface ReportOption {
reason: string
title: string
description: string
}
interface ReportOptions {
account: ReportOption[]
post: ReportOption[]
list: ReportOption[]
starterPack: ReportOption[]
feed: ReportOption[]
chatMessage: ReportOption[]
}
export function useReportOptions(): ReportOptions {
const {_} = useLingui()
return useMemo(() => {
const other = {
reason: ComAtprotoModerationDefs.REASONOTHER,
title: _(msg`Other`),
description: _(msg`An issue not included in these options`),
}
const common = [
{
reason: ComAtprotoModerationDefs.REASONRUDE,
title: _(msg`Anti-Social Behavior`),
description: _(msg`Harassment, trolling, or intolerance`),
},
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Illegal and Urgent`),
description: _(msg`Glaring violations of law or terms of service`),
},
other,
]
return {
account: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Account`),
description: _(
msg`Impersonation or false claims about identity or affiliation`,
),
},
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Frequently Posts Unwanted Content`),
description: _(msg`Spam; excessive mentions or replies`),
},
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
other,
],
post: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Post`),
description: _(msg`Impersonation, misinformation, or false claims`),
},
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Spam`),
description: _(msg`Excessive mentions or replies`),
},
{
reason: ComAtprotoModerationDefs.REASONSEXUAL,
title: _(msg`Unwanted Sexual Content`),
description: _(msg`Nudity or adult content not labeled as such`),
},
...common,
],
chatMessage: [
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Spam`),
description: _(msg`Excessive or unwanted messages`),
},
{
reason: ComAtprotoModerationDefs.REASONSEXUAL,
title: _(msg`Unwanted Sexual Content`),
description: _(msg`Inappropriate messages or explicit links`),
},
...common,
],
list: [
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
...common,
],
starterPack: [
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
...common,
],
feed: [
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
...common,
],
}
}, [_])
}