Copy over fresh report dialog based on old
This commit is contained in:
@@ -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,
|
||||
],
|
||||
}
|
||||
}, [_])
|
||||
}
|
||||
Reference in New Issue
Block a user