diff options
author | Eric Bailey <git@esb.lol> | 2025-02-28 17:14:02 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-02-28 17:14:02 -0600 |
commit | 3be9dde92d64ec540a9097f369d64580fae75fa0 (patch) | |
tree | 49fa640a660af26a51ffa9b3aab2d1b169210048 /src/components/moderation/ReportDialog | |
parent | 96f4f6359add6a4f2a37df8f17cf3f2f59f0a2a6 (diff) | |
download | voidsky-3be9dde92d64ec540a9097f369d64580fae75fa0.tar.zst |
New reporting flow (#7832)
* Add option to align web dialogs to top * Add new wait util * Pipe through feed view to feed components * Reset unneeded change to main * Copy over fresh report dialog based on old * Hack in temp testing data * Swap in new dialog in all cases but chat * Cleanup * Add load and initial error state * Fill in states * Add copyright link * Handle single labeler case * Comment out debug code * Improve centering of type in circles * Open details if Other is selected * Remove debug code * Tweak colors * Bump SDK * Tweak Admonition for better x-platform styles * Add retry button * Add close button * Remove todo not covered in this PR * Translate Retry
Diffstat (limited to 'src/components/moderation/ReportDialog')
-rw-r--r-- | src/components/moderation/ReportDialog/action.ts | 99 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/const.ts | 1 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/copy.ts | 49 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/index.tsx | 654 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/state.ts | 109 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/types.ts | 67 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/utils/parseReportSubject.ts | 91 | ||||
-rw-r--r-- | src/components/moderation/ReportDialog/utils/useReportOptions.ts | 121 |
8 files changed, 1191 insertions, 0 deletions
diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts new file mode 100644 index 000000000..fde2c7898 --- /dev/null +++ b/src/components/moderation/ReportDialog/action.ts @@ -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`, + }, + }) + } + }, + }) +} diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts new file mode 100644 index 000000000..30c9aff88 --- /dev/null +++ b/src/components/moderation/ReportDialog/const.ts @@ -0,0 +1 @@ +export const DMCA_LINK = 'https://bsky.social/about/support/copyright' diff --git a/src/components/moderation/ReportDialog/copy.ts b/src/components/moderation/ReportDialog/copy.ts new file mode 100644 index 000000000..87e199f98 --- /dev/null +++ b/src/components/moderation/ReportDialog/copy.ts @@ -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]) +} diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx new file mode 100644 index 000000000..7115324e6 --- /dev/null +++ b/src/components/moderation/ReportDialog/index.tsx @@ -0,0 +1,654 @@ +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 * as Admonition from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' +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 {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {createStaticClick, InlineLinkText, Link} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {useSubmitReportMutation} from './action' +import {DMCA_LINK} from './const' +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> + ) +} + +/** + * This should only be shown if the dialog is configured incorrectly by a + * developer, but nevertheless we should have a graceful fallback. + */ +function Invalid() { + const {_} = useLingui() + return ( + <Dialog.ScrollableInner label={_(msg`Report dialog`)}> + <Text style={[a.font_heavy, a.text_xl, a.leading_snug, a.pb_xs]}> + <Trans>Invalid report subject</Trans> + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + Something wasn't quite right with the data you're trying to report. + Please contact support. + </Trans> + </Text> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +function Inner(props: ReportDialogProps) { + const t = useTheme() + const {_} = useLingui() + const ref = React.useRef<ScrollView>(null) + const { + data: allLabelers, + isLoading: isLabelerLoading, + error: labelersLoadError, + refetch: refetchLabelers, + } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) + const isLoading = useDelayedLoading(500, isLabelerLoading) + const copy = useCopyForSubject(props.subject) + const reportOptions = useReportOptions() + const [state, dispatch] = React.useReducer(reducer, initialState) + + /** + * Submission handling + */ + const {mutateAsync: submitReport} = useSubmitReportMutation() + const [isPending, setPending] = React.useState(false) + const [isSuccess, setSuccess] = React.useState(false) + + /** + * Labelers that support this `subject` and its NSID collection + */ + const supportedLabelers = React.useMemo(() => { + if (!allLabelers) return [] + return allLabelers + .filter(l => { + 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 => { + 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 + const reasonTypes: string[] | undefined = l.reasonTypes + if (reasonTypes === undefined) return true + return reasonTypes.includes(state.selectedOption.reason) + }) + }, [props, allLabelers, state.selectedOption]) + const hasSupportedLabelers = !!supportedLabelers.length + const hasSingleSupportedLabeler = supportedLabelers.length === 1 + + const onSubmit = React.useCallback(async () => { + dispatch({type: 'clearError'}) + + try { + setPending(true) + // wait at least 1s, make it feel substantial + await wait( + 1e3, + submitReport({ + subject: props.subject, + state, + }), + ) + setSuccess(true) + // give time for user feedback + 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.gap_sm]}> + <OptionCardSkeleton /> + <OptionCardSkeleton /> + <OptionCardSkeleton /> + <OptionCardSkeleton /> + <OptionCardSkeleton /> + {/* Here to capture focus for a hot sec to prevent flash */} + <Pressable accessible={false} /> + </View> + ) : labelersLoadError || !allLabelers ? ( + <Admonition.Outer type="error"> + <Admonition.Row> + <Admonition.Icon /> + <Admonition.Text> + <Trans>Something went wrong, please try again</Trans> + </Admonition.Text> + <Admonition.Button + label={_(msg`Retry loading report options`)} + onPress={() => refetchLabelers()}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + <ButtonIcon icon={Retry} /> + </Admonition.Button> + </Admonition.Row> + </Admonition.Outer> + ) : ( + <> + {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]}> + {reportOptions[props.subject.type].map(o => ( + <OptionCard + key={o.reason} + option={o} + onSelect={() => { + dispatch({type: 'selectOption', option: o}) + }} + /> + ))} + + {['post', 'account'].includes(props.subject.type) && ( + <Link + to={DMCA_LINK} + label={_( + msg`View details for reporting a copyright violation`, + )}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_row, + a.align_center, + a.w_full, + a.px_md, + a.py_sm, + a.rounded_sm, + a.border, + hovered || pressed + ? [t.atoms.border_contrast_high] + : [t.atoms.border_contrast_low], + ]}> + <Text style={[a.flex_1, a.italic, a.leading_snug]}> + <Trans>Need to report a copyright violation?</Trans> + </Text> + <SquareArrowTopRight + size="sm" + fill={t.atoms.text.color} + /> + </View> + )} + </Link> + )} + </View> + )} + </> + )} + </StepOuter> + + <StepOuter> + <StepTitle + index={2} + title={_(msg`Select moderation service`)} + activeIndex1={state.activeStepIndex1} + /> + {state.activeStepIndex1 >= 2 && ( + <> + {state.selectedLabeler ? ( + <> + {hasSingleSupportedLabeler ? ( + <LabelerCard labeler={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 moderation service`)} + size="tiny" + variant="solid" + color="secondary" + shape="round" + onPress={() => { + dispatch({type: 'clearLabeler'}) + }}> + <ButtonIcon icon={X} /> + </Button> + </View> + )} + </> + ) : ( + <> + {hasSupportedLabelers ? ( + <View style={[a.gap_sm]}> + {hasSingleSupportedLabeler ? ( + <> + <LabelerCard labeler={supportedLabelers[0]} /> + <ActionOnce + check={() => !state.selectedLabeler} + callback={() => { + dispatch({ + type: 'selectLabeler', + labeler: supportedLabelers[0], + }) + }} + /> + </> + ) : ( + <> + {supportedLabelers.map(l => ( + <LabelerCard + key={l.creator.did} + labeler={l} + onSelect={() => { + dispatch({type: 'selectLabeler', labeler: l}) + }} + /> + ))} + </> + )} + </View> + ) : ( + // should never happen in our app + <Admonition.Admonition type="warning"> + <Trans> + Unfortunately, none of your subscribed labelers supports + this report type. + </Trans> + </Admonition.Admonition> + )} + </> + )} + </> + )} + </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> + . + </Trans>{' '} + {!state.detailsOpen ? ( + <InlineLinkText + label={_(msg`Add more details (optional)`)} + {...createStaticClick(() => { + dispatch({type: 'showDetails'}) + })}> + <Trans>Add more details (optional)</Trans> + </InlineLinkText> + ) : null} + </Text> + + {state.detailsOpen && ( + <View> + <Dialog.Input + multiline + value={state.details} + onChangeText={details => { + dispatch({type: 'setDetails', details}) + }} + label={_(msg`Additional details (limit 300 characters)`)} + 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.Admonition type="error"> + {state.error} + </Admonition.Admonition> + )} + </> + )} + </StepOuter> + </View> + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +function ActionOnce({ + check, + callback, +}: { + check: () => boolean + callback: () => void +}) { + React.useEffect(() => { + if (check()) { + callback() + } + }, [check, callback]) + return null +} + +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, a.pr_3xl]}> + <View + style={[ + a.justify_center, + a.align_center, + a.rounded_full, + a.border, + { + width: 24, + height: 24, + backgroundColor: active + ? t.palette.primary_500 + : completed + ? t.palette.primary_100 + : t.atoms.bg_contrast_25.backgroundColor, + borderColor: active + ? t.palette.primary_500 + : completed + ? t.palette.primary_400 + : t.atoms.border_contrast_low.borderColor, + }, + ]}> + {completed ? ( + <Check width={12} /> + ) : ( + <Text + style={[ + a.font_heavy, + a.text_center, + t.atoms.text, + { + color: active + ? 'white' + : completed + ? t.palette.primary_700 + : t.atoms.text_contrast_medium.color, + fontVariant: ['tabular-nums'], + width: 24, + height: 24, + lineHeight: 24, + }, + ]}> + {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 OptionCardSkeleton() { + const t = useTheme() + return ( + <View + style={[ + a.w_full, + a.rounded_sm, + a.border, + t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + {height: 55}, // magic, based on web + ]} + /> + ) +} + +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> + ) +} diff --git a/src/components/moderation/ReportDialog/state.ts b/src/components/moderation/ReportDialog/state.ts new file mode 100644 index 000000000..3f55bfb01 --- /dev/null +++ b/src/components/moderation/ReportDialog/state.ts @@ -0,0 +1,109 @@ +import {AppBskyLabelerDefs, ComAtprotoModerationDefs} 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, + detailsOpen: + !!state.details || + action.option.reason === ComAtprotoModerationDefs.REASONOTHER, + } + case 'clearOption': + return { + ...state, + selectedOption: undefined, + selectedLabeler: undefined, + activeStepIndex1: 1, + detailsOpen: + !!state.details || + state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, + } + case 'selectLabeler': + return { + ...state, + selectedLabeler: action.labeler, + activeStepIndex1: 3, + } + case 'clearLabeler': + return { + ...state, + selectedLabeler: undefined, + activeStepIndex1: 2, + detailsOpen: + !!state.details || + state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, + } + 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, + } + } +} diff --git a/src/components/moderation/ReportDialog/types.ts b/src/components/moderation/ReportDialog/types.ts new file mode 100644 index 000000000..444f01c66 --- /dev/null +++ b/src/components/moderation/ReportDialog/types.ts @@ -0,0 +1,67 @@ +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 +} diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts new file mode 100644 index 000000000..b79e49695 --- /dev/null +++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts @@ -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')), + }, + } + } + } +} diff --git a/src/components/moderation/ReportDialog/utils/useReportOptions.ts b/src/components/moderation/ReportDialog/utils/useReportOptions.ts new file mode 100644 index 000000000..38888d51a --- /dev/null +++ b/src/components/moderation/ReportDialog/utils/useReportOptions.ts @@ -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, + ], + } + }, [_]) +} |