diff options
20 files changed, 1277 insertions, 37 deletions
diff --git a/.eslintrc.js b/.eslintrc.js index 5f302dfd9..5505f45ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,7 @@ module.exports = { 'H6', 'P', 'Admonition', + 'Admonition.Admonition', ], impliedTextProps: [], suggestedTextWrappers: { diff --git a/package.json b/package.json index 3d69c1975..2ab966ceb 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.14.0", + "@atproto/api": "^0.14.7", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/components/Admonition.tsx b/src/components/Admonition.tsx index 8b01a8aba..8df4934be 100644 --- a/src/components/Admonition.tsx +++ b/src/components/Admonition.tsx @@ -2,6 +2,7 @@ import React from 'react' import {StyleProp, View, ViewStyle} from 'react-native' import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button as BaseButton, ButtonProps} from '#/components/Button' import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye' import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' @@ -49,22 +50,29 @@ export function Text({ return ( <BaseText {...rest} - style={[ - a.flex_1, - a.text_sm, - a.leading_snug, - { - paddingTop: 1, - }, - style, - ]}> + style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}> {children} </BaseText> ) } +export function Button({ + children, + ...props +}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) { + return ( + <BaseButton size="tiny" variant="outline" color="secondary" {...props}> + {children} + </BaseButton> + ) +} + export function Row({children}: {children: React.ReactNode}) { - return <View style={[a.flex_row, a.gap_sm]}>{children}</View> + return ( + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> + {children} + </View> + ) } export function Outer({ 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, + ], + } + }, [_]) +} diff --git a/src/lib/async/wait.ts b/src/lib/async/wait.ts new file mode 100644 index 000000000..ca37ac19c --- /dev/null +++ b/src/lib/async/wait.ts @@ -0,0 +1,5 @@ +export async function wait<T>(delay: number, fn: T): Promise<Awaited<T>> { + return await Promise.all([fn, new Promise(y => setTimeout(y, delay))]).then( + arr => arr[0], + ) +} diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx index 076e73ff9..4fd417c9c 100644 --- a/src/screens/Profile/components/ProfileFeedHeader.tsx +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -46,7 +46,10 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import * as Menu from '#/components/Menu' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' @@ -551,14 +554,15 @@ function DialogInner({ </Button> </View> - <ReportDialog - control={reportDialogControl} - params={{ - type: 'feedgen', - uri: info.uri, - cid: info.cid, - }} - /> + {info.view && ( + <ReportDialog + control={reportDialogControl} + subject={{ + ...info.view, + $type: 'app.bsky.feed.defs#generatorView', + }} + /> + )} </View> </> )} diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index cf153db44..7ec0043b1 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -55,8 +55,11 @@ import * as Layout from '#/components/Layout' import {ListMaybePlaceholder} from '#/components/Lists' import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' import {FeedsList} from '#/components/StarterPack/Main/FeedsList' import {PostsList} from '#/components/StarterPack/Main/PostsList' @@ -620,10 +623,9 @@ function OverflowMenu({ {starterPack.list && ( <ReportDialog control={reportDialogControl} - params={{ - type: 'starterpack', - uri: starterPack.uri, - cid: starterPack.cid, + subject={{ + ...starterPack, + $type: 'app.bsky.graph.defs#starterPackView', }} /> )} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index ce88febef..5571c0949 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -33,6 +33,7 @@ import {precacheResolvedUri} from './resolve-uri' export type FeedSourceFeedInfo = { type: 'feed' + view?: AppBskyFeedDefs.GeneratorView uri: string feedDescriptor: FeedDescriptor route: { @@ -53,6 +54,7 @@ export type FeedSourceFeedInfo = { export type FeedSourceListInfo = { type: 'list' + view?: AppBskyGraphDefs.ListView uri: string feedDescriptor: FeedDescriptor route: { @@ -93,6 +95,7 @@ export function hydrateFeedGenerator( return { type: 'feed', + view, uri: view.uri, feedDescriptor: `feedgen|${view.uri}`, cid: view.cid, @@ -126,6 +129,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo { return { type: 'list', + view, uri: view.uri, feedDescriptor: `list|${view.uri}`, route: { diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts index 1b4de8bf2..a5cab18dd 100644 --- a/src/state/queries/preferences/moderation.ts +++ b/src/state/queries/preferences/moderation.ts @@ -41,6 +41,7 @@ export function useMyLabelersQuery({ isLoading, error, data: labelers.data, + refetch: labelers.refetch, } }, [labelers, isLoading, error]) } diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 1d9c7a8e4..ab953f6fa 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -38,8 +38,11 @@ import { import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import * as Menu from '#/components/Menu' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' let ProfileMenu = ({ profile, @@ -365,7 +368,10 @@ let ProfileMenu = ({ <ReportDialog control={reportDialogControl} - params={{type: 'account', did: profile.did}} + subject={{ + ...profile, + $type: 'app.bsky.actor.defs#profileViewDetailed', + }} /> <Prompt.Basic diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx index f50c60173..c8ddf0f20 100644 --- a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx +++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx @@ -80,8 +80,11 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import * as Toast from '../Toast' let PostDropdownMenuItems = ({ @@ -756,10 +759,9 @@ let PostDropdownMenuItems = ({ <ReportDialog control={reportDialogControl} - params={{ - type: 'post', - uri: postUri, - cid: postCid, + subject={{ + ...post, + $type: 'app.bsky.feed.defs#postView', }} /> diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 435b09c07..89dc72410 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -75,8 +75,11 @@ import {useDialogControl} from '#/components/Dialog' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import * as Layout from '#/components/Layout' import * as Hider from '#/components/moderation/Hider' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' const SECTION_TITLES_CURATE = ['Posts', 'People'] @@ -672,10 +675,9 @@ function Header({ avatarType="list"> <ReportDialog control={reportDialogControl} - params={{ - type: 'list', - uri: list.uri, - cid: list.cid, + subject={{ + ...list, + $type: 'app.bsky.graph.defs#listView', }} /> {isCurateList ? ( diff --git a/yarn.lock b/yarn.lock index 01ca6f1b2..1e86174cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,6 +80,20 @@ tlds "^1.234.0" zod "^3.23.8" +"@atproto/api@^0.14.7": + version "0.14.7" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.7.tgz#3ffa02d6b3baf9e265dab170367ffade08023567" + integrity sha512-YG2kvAtsgtajLlLrorYuHcxGgepG0c/RUB2/iJyBnwKjGqDLG8joOETf38JSNiGzs6NJbNKa9NHG6BQKourxBA== + dependencies: + "@atproto/common-web" "^0.4.0" + "@atproto/lexicon" "^0.4.7" + "@atproto/syntax" "^0.3.3" + "@atproto/xrpc" "^0.6.9" + await-lock "^2.2.2" + multiformats "^9.9.0" + tlds "^1.234.0" + zod "^3.23.8" + "@atproto/aws@^0.2.15": version "0.2.15" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.15.tgz#edc534a420b4da37e2f049d471bf40df93447a25" |