From 3be9dde92d64ec540a9097f369d64580fae75fa0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 28 Feb 2025 17:14:02 -0600 Subject: 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 --- src/components/moderation/ReportDialog/index.tsx | 654 +++++++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 src/components/moderation/ReportDialog/index.tsx (limited to 'src/components/moderation/ReportDialog/index.tsx') 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 & { + subject: ReportSubject + }, +) { + const subject = React.useMemo( + () => parseReportSubject(props.subject), + [props.subject], + ) + return ( + + + {subject ? : } + + ) +} + +/** + * 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 ( + + + Invalid report subject + + + + Something wasn't quite right with the data you're trying to report. + Please contact support. + + + + + ) +} + +function Inner(props: ReportDialogProps) { + const t = useTheme() + const {_} = useLingui() + const ref = React.useRef(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 ( + + + + + {isLoading ? ( + + + + + + + {/* Here to capture focus for a hot sec to prevent flash */} + + + ) : labelersLoadError || !allLabelers ? ( + + + + + Something went wrong, please try again + + refetchLabelers()}> + + Retry + + + + + + ) : ( + <> + {state.selectedOption ? ( + + + + + + + ) : ( + + {reportOptions[props.subject.type].map(o => ( + { + dispatch({type: 'selectOption', option: o}) + }} + /> + ))} + + {['post', 'account'].includes(props.subject.type) && ( + + {({hovered, pressed}) => ( + + + Need to report a copyright violation? + + + + )} + + )} + + )} + + )} + + + + + {state.activeStepIndex1 >= 2 && ( + <> + {state.selectedLabeler ? ( + <> + {hasSingleSupportedLabeler ? ( + + ) : ( + + + + + + + )} + + ) : ( + <> + {hasSupportedLabelers ? ( + + {hasSingleSupportedLabeler ? ( + <> + + !state.selectedLabeler} + callback={() => { + dispatch({ + type: 'selectLabeler', + labeler: supportedLabelers[0], + }) + }} + /> + + ) : ( + <> + {supportedLabelers.map(l => ( + { + dispatch({type: 'selectLabeler', labeler: l}) + }} + /> + ))} + + )} + + ) : ( + // should never happen in our app + + + Unfortunately, none of your subscribed labelers supports + this report type. + + + )} + + )} + + )} + + + + + {state.activeStepIndex1 === 3 && ( + <> + + + + Your report will be sent to{' '} + + {state.selectedLabeler?.creator.displayName} + + . + {' '} + {!state.detailsOpen ? ( + { + dispatch({type: 'showDetails'}) + })}> + Add more details (optional) + + ) : null} + + + {state.detailsOpen && ( + + { + dispatch({type: 'setDetails', details}) + }} + label={_(msg`Additional details (limit 300 characters)`)} + style={{paddingRight: 60}} + numberOfLines={4} + /> + + + + + )} + + + + {state.error && ( + + {state.error} + + )} + + )} + + + + + + ) +} + +function ActionOnce({ + check, + callback, +}: { + check: () => boolean + callback: () => void +}) { + React.useEffect(() => { + if (check()) { + callback() + } + }, [check, callback]) + return null +} + +function StepOuter({children}: {children: React.ReactNode}) { + return {children} +} + +function StepTitle({ + index, + title, + activeIndex1, +}: { + index: number + title: string + activeIndex1: number +}) { + const t = useTheme() + const active = activeIndex1 === index + const completed = activeIndex1 > index + return ( + + + {completed ? ( + + ) : ( + + {index} + + )} + + + + {title} + + + ) +} + +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 ( + + ) +} + +function OptionCardSkeleton() { + const t = useTheme() + return ( + + ) +} + +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 ( + + ) +} -- cgit 1.4.1