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 (
)
}