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' const logger = Logger.create(Logger.Context.ReportDialog) export function ReportDialog( props: Omit & { subject: ReportSubject }, ) { const subject = React.useMemo( () => parseReportSubject(props.subject), [props.subject], ) const onClose = React.useCallback(() => { logger.metric('reportDialog:close', {}) }, []) 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'}) logger.info('submitting') try { setPending(true) // wait at least 1s, make it feel substantial await wait( 1e3, submitReport({ subject: props.subject, state, }), ) setSuccess(true) logger.metric('reportDialog:success', { reason: state.selectedOption?.reason!, labeler: state.selectedLabeler?.creator.handle!, details: !!state.details, }) // give time for user feedback setTimeout(() => { props.control.close() }, 1e3) } catch (e: any) { logger.metric('reportDialog:failure', {}) 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]) React.useEffect(() => { logger.metric('reportDialog:open', { subjectType: props.subject.type, }) }, [props.subject]) 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 ( ) }