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 --- .eslintrc.js | 1 + package.json | 2 +- src/components/Admonition.tsx | 28 +- src/components/moderation/ReportDialog/action.ts | 99 ++++ src/components/moderation/ReportDialog/const.ts | 1 + src/components/moderation/ReportDialog/copy.ts | 49 ++ src/components/moderation/ReportDialog/index.tsx | 654 +++++++++++++++++++++ src/components/moderation/ReportDialog/state.ts | 109 ++++ src/components/moderation/ReportDialog/types.ts | 67 +++ .../ReportDialog/utils/parseReportSubject.ts | 91 +++ .../ReportDialog/utils/useReportOptions.ts | 121 ++++ src/lib/async/wait.ts | 5 + .../Profile/components/ProfileFeedHeader.tsx | 22 +- src/screens/StarterPack/StarterPackScreen.tsx | 12 +- src/state/queries/feed.ts | 4 + src/state/queries/preferences/moderation.ts | 1 + src/view/com/profile/ProfileMenu.tsx | 10 +- .../com/util/forms/PostDropdownBtnMenuItems.tsx | 12 +- src/view/screens/ProfileList.tsx | 12 +- yarn.lock | 14 + 20 files changed, 1277 insertions(+), 37 deletions(-) create mode 100644 src/components/moderation/ReportDialog/action.ts create mode 100644 src/components/moderation/ReportDialog/const.ts create mode 100644 src/components/moderation/ReportDialog/copy.ts create mode 100644 src/components/moderation/ReportDialog/index.tsx create mode 100644 src/components/moderation/ReportDialog/state.ts create mode 100644 src/components/moderation/ReportDialog/types.ts create mode 100644 src/components/moderation/ReportDialog/utils/parseReportSubject.ts create mode 100644 src/components/moderation/ReportDialog/utils/useReportOptions.ts create mode 100644 src/lib/async/wait.ts 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 ( + style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}> {children} ) } +export function Button({ + children, + ...props +}: Omit) { + return ( + + {children} + + ) +} + export function Row({children}: {children: React.ReactNode}) { - return {children} + return ( + + {children} + + ) } 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 & { + subject: $Typed + }) + + 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 & { + 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 ( + + ) +} 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 + | $Typed + | $Typed + | $Typed + | $Typed + | $Typed + | $Typed + | {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( + 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(delay: number, fn: T): Promise> { + 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({ - + {info.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 && ( )} 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 = ({ 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"> {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" -- cgit 1.4.1