import React from 'react' import {Pressable, type ScrollView, View} from 'react-native' import {type AppBskyLabelerDefs, BSKY_LABELER_DID} 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 {useCallOnce} from '#/lib/once' import {sanitizeHandle} from '#/lib/strings/handles' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 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 {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate' 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 {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import {useSubmitReportMutation} from './action' import { BSKY_LABELER_ONLY_REPORT_REASONS, BSKY_LABELER_ONLY_SUBJECT_TYPES, NEW_TO_OLD_REASONS_MAP, SUPPORT_PAGE, } from './const' import {useCopyForSubject} from './copy' import {initialState, reducer} from './state' import {type ReportDialogProps, type ReportSubject} from './types' import {parseReportSubject} from './utils/parseReportSubject' import { type ReportCategoryConfig, type ReportOption, useReportOptions, } from './utils/useReportOptions' export {type ReportSubject} from './types' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' export function useGlobalReportDialogControl() { return useGlobalDialogsControlContext().reportDialogControl } export function GlobalReportDialog() { const {value, control} = useGlobalReportDialogControl() return } export function ReportDialog( props: Omit & { subject?: ReportSubject }, ) { const ax = useAnalytics() const subject = React.useMemo( () => (props.subject ? parseReportSubject(props.subject) : undefined), [props.subject], ) const onClose = React.useCallback(() => { ax.metric('reportDialog:close', {}) }, [ax]) 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 ax = useAnalytics() const logger = ax.logger.useChild(ax.logger.Context.ReportDialog) 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 {categories, getCategory} = useReportOptions() const [state, dispatch] = React.useReducer(reducer, initialState) const enableSquareButtons = useEnableSquareButtons() /** * Submission handling */ const {mutateAsync: submitReport} = useSubmitReportMutation() const [isPending, setPending] = React.useState(false) const [isSuccess, setSuccess] = React.useState(false) // some reasons ONLY go to Bluesky const isBskyOnlyReason = state?.selectedOption?.reason ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) : false // some subjects ONLY go to Bluesky const isBskyOnlySubject = BSKY_LABELER_ONLY_SUBJECT_TYPES.has( props.subject.type, ) /** * 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 === 'convoMessage') { 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 === 'convoMessage') return true return collections.includes(props.subject.nsid) }) .filter(l => { if (!state.selectedOption) return false if (isBskyOnlyReason || isBskyOnlySubject) { return l.creator.did === BSKY_LABELER_DID } const supportedReasonTypes: string[] | undefined = l.reasonTypes if (supportedReasonTypes === undefined) return true return ( // supports new reason type supportedReasonTypes.includes(state.selectedOption.reason) || // supports old reason type (backwards compat) supportedReasonTypes.includes( NEW_TO_OLD_REASONS_MAP[state.selectedOption.reason], ) ) }) }, [ props, allLabelers, state.selectedOption, isBskyOnlyReason, isBskyOnlySubject, ]) const hasSupportedLabelers = !!supportedLabelers.length const hasSingleSupportedLabeler = supportedLabelers.length === 1 /** * We skip the select labeler step if there's only one possible labeler, and * that labeler is Bluesky (which is the case for chat reports and certain * reason types). We'll use this below to adjust the indexing and skip the * step in the UI. */ const isAlwaysBskyLabeler = hasSingleSupportedLabeler && (isBskyOnlyReason || isBskyOnlySubject) 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) ax.metric('reportDialog:success', { reason: state.selectedOption?.reason ?? '', labeler: state.selectedLabeler?.creator.handle ?? '', details: !!state.details, }) // give time for user feedback setTimeout(() => { props.control.close(() => { props.onAfterSubmit?.() }) }, 1e3) } catch (e: any) { ax.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]) useCallOnce(() => { ax.metric('reportDialog:open', { subjectType: props.subject.type, }) })() return ( {isLoading ? ( {/* Here to capture focus for a hot sec to prevent flash */} ) : labelersLoadError || !allLabelers ? ( Something went wrong, please try again refetchLabelers()}> Retry ) : ( <> {state.selectedCategory ? ( ) : ( {categories.map(o => ( { dispatch({ type: 'selectCategory', option: o, otherOption: getCategory('other').options[0], }) }} /> ))} {['post', 'account'].includes(props.subject.type) && ( {({hovered, pressed}) => ( Need to report a copyright violation, legal request, or regulatory compliance issue? )} )} )} )} {state.selectedOption ? ( ) : state.selectedCategory ? ( {getCategory(state.selectedCategory.key).options.map(o => ( { dispatch({type: 'selectOption', option: o}) }} /> ))} ) : null} {isAlwaysBskyLabeler ? ( !state.selectedLabeler} callback={() => { dispatch({ type: 'selectLabeler', labeler: supportedLabelers[0], }) }} /> ) : ( {state.activeStepIndex1 >= 3 && ( <> {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 === 4 && ( <> 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 const enableSquareButtons = useEnableSquareButtons() return ( {completed ? ( ) : ( {index} )} {title} ) } function CategoryCard({ option, onSelect, }: { option: ReportCategoryConfig onSelect?: (option: ReportCategoryConfig) => void }) { const t = useTheme() const {_} = useLingui() const gutters = useGutters(['compact']) const onPress = React.useCallback(() => { onSelect?.(option) }, [onSelect, option]) return ( ) } 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 ( ) }