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
This commit is contained in:
@@ -32,6 +32,7 @@ module.exports = {
|
|||||||
'H6',
|
'H6',
|
||||||
'P',
|
'P',
|
||||||
'Admonition',
|
'Admonition',
|
||||||
|
'Admonition.Admonition',
|
||||||
],
|
],
|
||||||
impliedTextProps: [],
|
impliedTextProps: [],
|
||||||
suggestedTextWrappers: {
|
suggestedTextWrappers: {
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.14.0",
|
"@atproto/api": "^0.14.7",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
|
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
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 {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||||
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
|
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
|
||||||
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
|
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
|
||||||
@@ -49,22 +50,29 @@ export function Text({
|
|||||||
return (
|
return (
|
||||||
<BaseText
|
<BaseText
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[
|
style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
|
||||||
a.flex_1,
|
|
||||||
a.text_sm,
|
|
||||||
a.leading_snug,
|
|
||||||
{
|
|
||||||
paddingTop: 1,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}>
|
|
||||||
{children}
|
{children}
|
||||||
</BaseText>
|
</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}) {
|
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({
|
export function Outer({
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [_])
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,7 +46,10 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
|||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import * as Menu from '#/components/Menu'
|
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 {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -551,14 +554,15 @@ function DialogInner({
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ReportDialog
|
{info.view && (
|
||||||
control={reportDialogControl}
|
<ReportDialog
|
||||||
params={{
|
control={reportDialogControl}
|
||||||
type: 'feedgen',
|
subject={{
|
||||||
uri: info.uri,
|
...info.view,
|
||||||
cid: info.cid,
|
$type: 'app.bsky.feed.defs#generatorView',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ import * as Layout from '#/components/Layout'
|
|||||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {
|
||||||
|
ReportDialog,
|
||||||
|
useReportDialogControl,
|
||||||
|
} from '#/components/moderation/ReportDialog'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {FeedsList} from '#/components/StarterPack/Main/FeedsList'
|
import {FeedsList} from '#/components/StarterPack/Main/FeedsList'
|
||||||
import {PostsList} from '#/components/StarterPack/Main/PostsList'
|
import {PostsList} from '#/components/StarterPack/Main/PostsList'
|
||||||
@@ -620,10 +623,9 @@ function OverflowMenu({
|
|||||||
{starterPack.list && (
|
{starterPack.list && (
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
params={{
|
subject={{
|
||||||
type: 'starterpack',
|
...starterPack,
|
||||||
uri: starterPack.uri,
|
$type: 'app.bsky.graph.defs#starterPackView',
|
||||||
cid: starterPack.cid,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {precacheResolvedUri} from './resolve-uri'
|
|||||||
|
|
||||||
export type FeedSourceFeedInfo = {
|
export type FeedSourceFeedInfo = {
|
||||||
type: 'feed'
|
type: 'feed'
|
||||||
|
view?: AppBskyFeedDefs.GeneratorView
|
||||||
uri: string
|
uri: string
|
||||||
feedDescriptor: FeedDescriptor
|
feedDescriptor: FeedDescriptor
|
||||||
route: {
|
route: {
|
||||||
@@ -53,6 +54,7 @@ export type FeedSourceFeedInfo = {
|
|||||||
|
|
||||||
export type FeedSourceListInfo = {
|
export type FeedSourceListInfo = {
|
||||||
type: 'list'
|
type: 'list'
|
||||||
|
view?: AppBskyGraphDefs.ListView
|
||||||
uri: string
|
uri: string
|
||||||
feedDescriptor: FeedDescriptor
|
feedDescriptor: FeedDescriptor
|
||||||
route: {
|
route: {
|
||||||
@@ -93,6 +95,7 @@ export function hydrateFeedGenerator(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'feed',
|
type: 'feed',
|
||||||
|
view,
|
||||||
uri: view.uri,
|
uri: view.uri,
|
||||||
feedDescriptor: `feedgen|${view.uri}`,
|
feedDescriptor: `feedgen|${view.uri}`,
|
||||||
cid: view.cid,
|
cid: view.cid,
|
||||||
@@ -126,6 +129,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'list',
|
type: 'list',
|
||||||
|
view,
|
||||||
uri: view.uri,
|
uri: view.uri,
|
||||||
feedDescriptor: `list|${view.uri}`,
|
feedDescriptor: `list|${view.uri}`,
|
||||||
route: {
|
route: {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function useMyLabelersQuery({
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
data: labelers.data,
|
data: labelers.data,
|
||||||
|
refetch: labelers.refetch,
|
||||||
}
|
}
|
||||||
}, [labelers, isLoading, error])
|
}, [labelers, isLoading, error])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ import {
|
|||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {
|
||||||
|
ReportDialog,
|
||||||
|
useReportDialogControl,
|
||||||
|
} from '#/components/moderation/ReportDialog'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
|
||||||
|
|
||||||
let ProfileMenu = ({
|
let ProfileMenu = ({
|
||||||
profile,
|
profile,
|
||||||
@@ -365,7 +368,10 @@ let ProfileMenu = ({
|
|||||||
|
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
params={{type: 'account', did: profile.did}}
|
subject={{
|
||||||
|
...profile,
|
||||||
|
$type: 'app.bsky.actor.defs#profileViewDetailed',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
|
|||||||
@@ -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 {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {
|
||||||
|
ReportDialog,
|
||||||
|
useReportDialogControl,
|
||||||
|
} from '#/components/moderation/ReportDialog'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
|
||||||
import * as Toast from '../Toast'
|
import * as Toast from '../Toast'
|
||||||
|
|
||||||
let PostDropdownMenuItems = ({
|
let PostDropdownMenuItems = ({
|
||||||
@@ -756,10 +759,9 @@ let PostDropdownMenuItems = ({
|
|||||||
|
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
params={{
|
subject={{
|
||||||
type: 'post',
|
...post,
|
||||||
uri: postUri,
|
$type: 'app.bsky.feed.defs#postView',
|
||||||
cid: postCid,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,11 @@ import {useDialogControl} from '#/components/Dialog'
|
|||||||
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import * as Hider from '#/components/moderation/Hider'
|
import * as Hider from '#/components/moderation/Hider'
|
||||||
|
import {
|
||||||
|
ReportDialog,
|
||||||
|
useReportDialogControl,
|
||||||
|
} from '#/components/moderation/ReportDialog'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
|
|
||||||
const SECTION_TITLES_CURATE = ['Posts', 'People']
|
const SECTION_TITLES_CURATE = ['Posts', 'People']
|
||||||
@@ -672,10 +675,9 @@ function Header({
|
|||||||
avatarType="list">
|
avatarType="list">
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
control={reportDialogControl}
|
control={reportDialogControl}
|
||||||
params={{
|
subject={{
|
||||||
type: 'list',
|
...list,
|
||||||
uri: list.uri,
|
$type: 'app.bsky.graph.defs#listView',
|
||||||
cid: list.cid,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isCurateList ? (
|
{isCurateList ? (
|
||||||
|
|||||||
@@ -80,6 +80,20 @@
|
|||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
zod "^3.23.8"
|
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":
|
"@atproto/aws@^0.2.15":
|
||||||
version "0.2.15"
|
version "0.2.15"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.15.tgz#edc534a420b4da37e2f049d471bf40df93447a25"
|
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.15.tgz#edc534a420b4da37e2f049d471bf40df93447a25"
|
||||||
|
|||||||
Reference in New Issue
Block a user