diff --git a/.eslintrc.js b/.eslintrc.js
index 5f302dfd9f..5505f45ed1 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 3d69c19753..2ab966ceb5 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 8b01a8abaf..8df4934bea 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 0000000000..fde2c78988
--- /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 0000000000..30c9aff88d
--- /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 0000000000..87e199f989
--- /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 0000000000..7115324e6a
--- /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 0000000000..3f55bfb01a
--- /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 0000000000..444f01c66e
--- /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 0000000000..b79e496958
--- /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 0000000000..38888d51a4
--- /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 0000000000..ca37ac19c3
--- /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 076e73ff93..4fd417c9cc 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 cf153db448..7ec0043b1d 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 ce88febef9..5571c0949d 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 1b4de8bf22..a5cab18ddd 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 1d9c7a8e4a..ab953f6fab 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 435b09c07d..89dc724103 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 01ca6f1b21..1e86174cb9 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"