Add ability to report a group chat (#10485)

This commit is contained in:
DS Boyce
2026-05-27 09:30:33 -07:00
committed by GitHub
parent 731f32abda
commit 4e4e1e1190
15 changed files with 452 additions and 158 deletions
-5
View File
@@ -202,11 +202,6 @@
"count": 2 "count": 2
} }
}, },
"src/components/moderation/ReportDialog/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/features/liveNow/index.tsx": { "src/features/liveNow/index.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 3 "count": 3
@@ -0,0 +1,258 @@
import {memo, useState} from 'react'
import {View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useProfileBlockMutationQueue,
useProfileQuery,
} from '#/state/queries/profile'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
type ReportDialogParams = {
convoId: string
did: string
}
/**
* Dialog shown after a report is submitted, allowing the user to block the
* reporter and/or leave the conversation.
*/
export const AfterReportConversationDialog = memo(
function BlockOrLeaveDialogInner({
control,
params,
currentScreen,
}: {
control: Dialog.DialogControlProps
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}): React.ReactNode {
const {t: l} = useLingui()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={l`Would you like to block this user and/or leave this conversation?`}
style={[web({maxWidth: 400})]}>
<DialogInner params={params} currentScreen={currentScreen} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
},
)
function DialogInner({
params,
currentScreen,
}: {
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}) {
const t = useTheme()
const {t: l} = useLingui()
const control = Dialog.useDialogContext()
const {
data: profile,
isPending,
isError,
} = useProfileQuery({
did: params.did,
})
return isPending ? (
<View style={[a.w_full, a.py_5xl, a.align_center]}>
<Loader size="lg" />
</View>
) : isError || !profile ? (
<View style={[a.w_full, a.gap_lg]}>
<View style={[a.justify_center, a.gap_sm]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Report submitted</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Our moderation team has received your report.</Trans>
</Text>
</View>
<Button
label={l`Close`}
onPress={() => control.close()}
size="large"
color="secondary">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
) : (
<DoneStep
convoId={params.convoId}
currentScreen={currentScreen}
profile={profile}
/>
)
}
function DoneStep({
convoId,
currentScreen,
profile,
}: {
convoId: string
currentScreen: 'list' | 'conversation'
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints()
const t = useTheme()
const [actions, setActions] = useState<string[]>(['block', 'leave'])
const shadow = useProfileShadow(profile)
const [queueBlock] = useProfileBlockMutationQueue(shadow)
const handleActionsChange = (newActions: string[]) => {
const hadBlock = actions.includes('block')
const hasBlock = newActions.includes('block')
// If block was just checked, ensure leave is also checked
if (!hadBlock && hasBlock) {
if (!newActions.includes('leave')) {
setActions([...newActions, 'leave'])
} else {
setActions(newActions)
}
}
// If block was just unchecked, also uncheck leave
else if (hadBlock && !hasBlock) {
setActions(newActions.filter(action => action !== 'leave'))
}
// Otherwise, use the new actions as-is (user can toggle leave independently)
else {
setActions(newActions)
}
}
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.dispatch(
StackActions.replace('Messages', IS_NATIVE ? {animation: 'pop'} : {}),
)
}
},
onError: () => {
Toast.show(l`Could not leave chat`, {
type: 'error',
})
},
})
let btnText = l`Done`
let toastMsg: string | undefined
if (actions.includes('leave') && actions.includes('block')) {
btnText = l({
message: 'Block and leave',
context: 'button',
comment: 'After-report action for a conversation',
})
toastMsg = l({message: 'Conversation left', context: 'toast'})
} else if (actions.includes('leave')) {
btnText = l({
message: 'Leave conversation',
context: 'button',
comment: 'After-report action for a conversation',
})
toastMsg = l({message: 'Conversation left', context: 'toast'})
} else if (actions.includes('block')) {
// Shouldn't be able to reach this, but here for completeness.
btnText = l({
message: 'Block user',
context: 'button',
comment: 'After-report action for a conversation',
})
toastMsg = l({message: 'User blocked', context: 'toast'})
}
const onPressPrimaryAction = () => {
control.close(() => {
if (actions.includes('block')) {
void queueBlock()
}
if (actions.includes('leave')) {
leaveConvo()
}
if (toastMsg) {
Toast.show(toastMsg, {
type: 'success',
})
}
})
}
return (
<View style={a.gap_2xl}>
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Report submitted</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Our moderation team has received your report.</Trans>
</Text>
</View>
<Toggle.Group
label={l`Block user and/or leave this conversation`}
values={actions}
onChange={handleActionsChange}>
<View style={[a.gap_md]}>
<Toggle.Item name="block" label={l`Block user`}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Block user</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="leave"
label={l`Leave conversation`}
disabled={actions.includes('block')}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Leave conversation</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
<View style={[a.gap_sm]}>
<Button
label={btnText}
onPress={onPressPrimaryAction}
size="large"
color={actions.length > 0 ? 'negative' : 'primary'}>
<ButtonText>{btnText}</ButtonText>
</Button>
<Button
label={l`Close`}
onPress={() => control.close()}
size="large"
color="secondary">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
+22 -27
View File
@@ -1,9 +1,7 @@
import {memo, useState} from 'react' import {memo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' import {type AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native' import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
@@ -24,7 +22,7 @@ import {IS_NATIVE} from '#/env'
type ReportDialogParams = { type ReportDialogParams = {
convoId: string convoId: string
message: ChatBskyConvoDefs.MessageView did: string
} }
/** /**
@@ -40,14 +38,12 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
params: ReportDialogParams params: ReportDialogParams
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
}): React.ReactNode { }): React.ReactNode {
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_( label={l`Would you like to block this user and/or delete this conversation?`}
msg`Would you like to block this user and/or delete this conversation?`,
)}
style={[web({maxWidth: 400})]}> style={[web({maxWidth: 400})]}>
<DialogInner params={params} currentScreen={currentScreen} /> <DialogInner params={params} currentScreen={currentScreen} />
<Dialog.Close /> <Dialog.Close />
@@ -64,14 +60,14 @@ function DialogInner({
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const { const {
data: profile, data: profile,
isPending, isPending,
isError, isError,
} = useProfileQuery({ } = useProfileQuery({
did: params.message.sender.did, did: params.did,
}) })
return isPending ? ( return isPending ? (
@@ -90,7 +86,7 @@ function DialogInner({
</View> </View>
<Button <Button
label={_(msg`Close`)} label={l`Close`}
onPress={() => control.close()} onPress={() => control.close()}
size={platform({native: 'small', web: 'large'})} size={platform({native: 'small', web: 'large'})}
color="secondary"> color="secondary">
@@ -117,7 +113,7 @@ function DoneStep({
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -135,29 +131,29 @@ function DoneStep({
} }
}, },
onError: () => { onError: () => {
Toast.show(_(msg`Could not leave chat`), { Toast.show(l`Could not leave chat`, {
type: 'error', type: 'error',
}) })
}, },
}) })
let btnText = _(msg`Done`) let btnText = l`Done`
let toastMsg: string | undefined let toastMsg: string | undefined
if (actions.includes('leave') && actions.includes('block')) { if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`) btnText = l`Block and delete`
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) toastMsg = l({message: 'Conversation deleted', context: 'toast'})
} else if (actions.includes('leave')) { } else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`) btnText = l`Delete conversation`
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) toastMsg = l({message: 'Conversation deleted', context: 'toast'})
} else if (actions.includes('block')) { } else if (actions.includes('block')) {
btnText = _(msg`Block User`) btnText = l`Block user`
toastMsg = _(msg({message: 'User blocked', context: 'toast'})) toastMsg = l({message: 'User blocked', context: 'toast'})
} }
const onPressPrimaryAction = () => { const onPressPrimaryAction = () => {
control.close(() => { control.close(() => {
if (actions.includes('block')) { if (actions.includes('block')) {
queueBlock() void queueBlock()
} }
if (actions.includes('leave')) { if (actions.includes('leave')) {
leaveConvo() leaveConvo()
@@ -181,17 +177,17 @@ function DoneStep({
</Text> </Text>
</View> </View>
<Toggle.Group <Toggle.Group
label={_(msg`Block user and/or delete this conversation`)} label={l`Block user and/or delete this conversation`}
values={actions} values={actions}
onChange={setActions}> onChange={setActions}>
<View style={[a.gap_md]}> <View style={[a.gap_md]}>
<Toggle.Item name="block" label={_(msg`Block user`)}> <Toggle.Item name="block" label={l`Block user`}>
<Toggle.Checkbox /> <Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}> <Toggle.LabelText style={[a.text_md]}>
<Trans>Block user</Trans> <Trans>Block user</Trans>
</Toggle.LabelText> </Toggle.LabelText>
</Toggle.Item> </Toggle.Item>
<Toggle.Item name="leave" label={_(msg`Delete conversation`)}> <Toggle.Item name="leave" label={l`Delete conversation`}>
<Toggle.Checkbox /> <Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}> <Toggle.LabelText style={[a.text_md]}>
<Trans>Delete conversation</Trans> <Trans>Delete conversation</Trans>
@@ -199,7 +195,6 @@ function DoneStep({
</Toggle.Item> </Toggle.Item>
</View> </View>
</Toggle.Group> </Toggle.Group>
<View style={[a.gap_sm]}> <View style={[a.gap_sm]}>
<Button <Button
label={btnText} label={btnText}
@@ -209,7 +204,7 @@ function DoneStep({
<ButtonText>{btnText}</ButtonText> <ButtonText>{btnText}</ButtonText>
</Button> </Button>
<Button <Button
label={_(msg`Close`)} label={l`Close`}
onPress={() => control.close()} onPress={() => control.close()}
size="large" size="large"
color="secondary"> color="secondary">
+20 -3
View File
@@ -22,7 +22,7 @@ import {Button, ButtonIcon} from '#/components/Button'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
@@ -39,6 +39,7 @@ import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {AfterReportConversationDialog} from './AfterReportConversationDialog'
let ConvoMenu = ({ let ConvoMenu = ({
convo, convo,
@@ -71,6 +72,7 @@ let ConvoMenu = ({
const reportControl = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl()
const blockedByListControl = Prompt.usePromptControl() const blockedByListControl = Prompt.usePromptControl()
const blockOrDeleteControl = Prompt.usePromptControl() const blockOrDeleteControl = Prompt.usePromptControl()
const deleteControl = Prompt.usePromptControl()
const {listBlocks} = blockInfo const {listBlocks} = blockInfo
@@ -141,12 +143,27 @@ let ConvoMenu = ({
currentScreen={currentScreen} currentScreen={currentScreen}
params={{ params={{
convoId: convo.id, convoId: convo.id,
message: latestReportableMessage, did: latestReportableMessage.sender.did,
}} }}
/> />
</> </>
) : ( ) : (
<ReportConversationPrompt control={reportControl} /> <>
<ReportConversationDialog
control={reportControl}
convoId={convo.id}
did={profile.did}
onAfterSubmit={deleteControl.open}
/>
<AfterReportConversationDialog
control={deleteControl}
currentScreen={currentScreen}
params={{
convoId: convo.id,
did: profile.did,
}}
/>
</>
)} )}
<BlockedByListDialog <BlockedByListDialog
control={blockedByListControl} control={blockedByListControl}
+1 -1
View File
@@ -214,7 +214,7 @@ export let MessageContextMenu = ({
currentScreen="conversation" currentScreen="conversation"
params={{ params={{
convoId: convo.convo.view.id, convoId: convo.convo.view.id,
message, did: message.sender.did,
}} }}
/> />
<Prompt.Basic <Prompt.Basic
@@ -0,0 +1,22 @@
import {type DialogControlProps} from '#/components/Dialog'
import {ReportDialog} from '#/components/moderation/ReportDialog'
export function ReportConversationDialog({
control,
convoId,
did,
onAfterSubmit,
}: {
control: DialogControlProps
convoId: string
did: string
onAfterSubmit?: () => void
}) {
return (
<ReportDialog
control={control}
subject={{convoId, did}}
onAfterSubmit={onAfterSubmit}
/>
)
}
@@ -1,26 +0,0 @@
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {type DialogControlProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
export function ReportConversationPrompt({
control,
}: {
control: DialogControlProps
}) {
const {_} = useLingui()
return (
<Prompt.Basic
control={control}
title={_(msg`Report conversation`)}
description={_(
msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
)}
confirmButtonCta={_(msg`I understand`)}
onConfirm={() => {}}
showCancel={false}
/>
)
}
@@ -55,7 +55,9 @@ export function useSubmitReportMutation() {
let report: let report:
| ComAtprotoModerationCreateReport.InputSchema | ComAtprotoModerationCreateReport.InputSchema
| (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & { | (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & {
subject: $Typed<ChatBskyConvoDefs.MessageRef> subject:
| $Typed<ChatBskyConvoDefs.MessageRef>
| $Typed<ChatBskyConvoDefs.ConvoRef>
}) })
switch (subject.type) { switch (subject.type) {
@@ -99,6 +101,18 @@ export function useSubmitReportMutation() {
} }
break break
} }
case 'convo': {
report = {
reasonType,
reason: state.details,
subject: {
$type: 'chat.bsky.convo.defs#convoRef',
convoId: subject.convoId,
did: subject.did,
},
}
break
}
} }
if (__DEV__) { if (__DEV__) {
@@ -120,4 +120,4 @@ export const BSKY_LABELER_ONLY_REPORT_REASONS: Set<OzoneReportDefs.ReasonType> =
* moderation service. * moderation service.
*/ */
export const BSKY_LABELER_ONLY_SUBJECT_TYPES: Set<ParsedReportSubject['type']> = export const BSKY_LABELER_ONLY_SUBJECT_TYPES: Set<ParsedReportSubject['type']> =
new Set(['convoMessage', 'status']) new Set(['convoMessage', 'convo', 'status'])
@@ -60,6 +60,12 @@ export function useCopyForSubject(subject: ParsedReportSubject) {
} }
} }
} }
case 'convo': {
return {
title: _(msg`Report this conversation`),
subtitle: _(msg`Why should this conversation be reviewed?`),
}
}
} }
}, [_, subject]) }, [_, subject])
} }
@@ -8,9 +8,7 @@ import {
} from 'react' } from 'react'
import {Pressable, type ScrollView, View} from 'react-native' import {Pressable, type ScrollView, View} from 'react-native'
import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api' import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
@@ -93,9 +91,9 @@ export function ReportDialog(
* developer, but nevertheless we should have a graceful fallback. * developer, but nevertheless we should have a graceful fallback.
*/ */
function Invalid() { function Invalid() {
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)}> <Dialog.ScrollableInner label={l`Report dialog`}>
<Text style={[a.font_bold, a.text_xl, a.leading_snug, a.pb_xs]}> <Text style={[a.font_bold, a.text_xl, a.leading_snug, a.pb_xs]}>
<Trans>Invalid report subject</Trans> <Trans>Invalid report subject</Trans>
</Text> </Text>
@@ -114,7 +112,7 @@ function Inner(props: ReportDialogProps) {
const ax = useAnalytics() const ax = useAnalytics()
const logger = ax.logger.useChild(ax.logger.Context.ReportDialog) const logger = ax.logger.useChild(ax.logger.Context.ReportDialog)
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const ref = useRef<ScrollView>(null) const ref = useRef<ScrollView>(null)
const { const {
data: allLabelers, data: allLabelers,
@@ -131,8 +129,8 @@ function Inner(props: ReportDialogProps) {
* Submission handling * Submission handling
*/ */
const {mutateAsync: submitReport} = useSubmitReportMutation() const {mutateAsync: submitReport} = useSubmitReportMutation()
const [isPending, setPending] = useState(false) const [isPending, setIsPending] = useState(false)
const [isSuccess, setSuccess] = useState(false) const [isSuccess, setIsSuccess] = useState(false)
// some reasons ONLY go to Bluesky // some reasons ONLY go to Bluesky
const isBskyOnlyReason = state?.selectedOption?.reason const isBskyOnlyReason = state?.selectedOption?.reason
@@ -154,7 +152,10 @@ function Inner(props: ReportDialogProps) {
if (subjectTypes === undefined) return true if (subjectTypes === undefined) return true
if (props.subject.type === 'account') { if (props.subject.type === 'account') {
return subjectTypes.includes('account') return subjectTypes.includes('account')
} else if (props.subject.type === 'convoMessage') { } else if (
props.subject.type === 'convoMessage' ||
props.subject.type === 'convo'
) {
return subjectTypes.includes('chat') return subjectTypes.includes('chat')
} else { } else {
return subjectTypes.includes('record') return subjectTypes.includes('record')
@@ -164,7 +165,11 @@ function Inner(props: ReportDialogProps) {
const collections: string[] | undefined = l.subjectCollections const collections: string[] | undefined = l.subjectCollections
if (collections === undefined) return true if (collections === undefined) return true
// all chat collections accepted, since only Bluesky handles chats // all chat collections accepted, since only Bluesky handles chats
if (props.subject.type === 'convoMessage') return true if (
props.subject.type === 'convoMessage' ||
props.subject.type === 'convo'
)
return true
return collections.includes(props.subject.nsid) return collections.includes(props.subject.nsid)
}) })
.filter(l => { .filter(l => {
@@ -208,7 +213,7 @@ function Inner(props: ReportDialogProps) {
logger.info('submitting') logger.info('submitting')
try { try {
setPending(true) setIsPending(true)
// wait at least 1s, make it feel substantial // wait at least 1s, make it feel substantial
await wait( await wait(
1e3, 1e3,
@@ -217,7 +222,7 @@ function Inner(props: ReportDialogProps) {
state, state,
}), }),
) )
setSuccess(true) setIsSuccess(true)
ax.metric('reportDialog:success', { ax.metric('reportDialog:success', {
reason: state.selectedOption?.reason ?? '', reason: state.selectedOption?.reason ?? '',
labeler: state.selectedLabeler?.creator.handle ?? '', labeler: state.selectedLabeler?.creator.handle ?? '',
@@ -229,29 +234,20 @@ function Inner(props: ReportDialogProps) {
props.onAfterSubmit?.() props.onAfterSubmit?.()
}) })
}, 1e3) }, 1e3)
} catch (e: any) { } catch (err) {
const e = err as Error
ax.metric('reportDialog:failure', {}) ax.metric('reportDialog:failure', {})
logger.error(e, { logger.error(e, {
source: 'ReportDialog', source: 'ReportDialog',
}) })
dispatch({ dispatch({
type: 'setError', type: 'setError',
error: _(msg`Something went wrong. Please try again.`), error: l`Something went wrong. Please try again.`,
}) })
} finally { } finally {
setPending(false) setIsPending(false)
} }
}, [ }, [logger, submitReport, props, state, ax, l])
_,
submitReport,
state,
dispatch,
props.subject,
props.control,
props.onAfterSubmit,
setPending,
setSuccess,
])
useCallOnce(() => { useCallOnce(() => {
ax.metric('reportDialog:open', { ax.metric('reportDialog:open', {
@@ -262,7 +258,7 @@ function Inner(props: ReportDialogProps) {
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
testID="report:dialog" testID="report:dialog"
label={_(msg`Report dialog`)} label={l`Report dialog`}
ref={ref} ref={ref}
style={[a.w_full, {maxWidth: 500}]}> style={[a.w_full, {maxWidth: 500}]}>
<View style={[a.gap_2xl, IS_NATIVE && a.pt_md]}> <View style={[a.gap_2xl, IS_NATIVE && a.pt_md]}>
@@ -293,8 +289,8 @@ function Inner(props: ReportDialogProps) {
</Admonition.Content> </Admonition.Content>
<Admonition.Button <Admonition.Button
color="negative_subtle" color="negative_subtle"
label={_(msg`Retry loading report options`)} label={l`Retry loading report options`}
onPress={() => refetchLabelers()}> onPress={() => void refetchLabelers()}>
<ButtonText> <ButtonText>
<Trans>Retry</Trans> <Trans>Retry</Trans>
</ButtonText> </ButtonText>
@@ -311,7 +307,7 @@ function Inner(props: ReportDialogProps) {
</View> </View>
<Button <Button
testID="report:clearCategory" testID="report:clearCategory"
label={_(msg`Change report category`)} label={l`Change report category`}
size="tiny" size="tiny"
variant="solid" variant="solid"
color="secondary" color="secondary"
@@ -341,9 +337,7 @@ function Inner(props: ReportDialogProps) {
{['post', 'account'].includes(props.subject.type) && ( {['post', 'account'].includes(props.subject.type) && (
<Link <Link
to={SUPPORT_PAGE} to={SUPPORT_PAGE}
label={_( label={l`Need to report a copyright violation, legal request, or regulatory compliance issue?`}>
msg`Need to report a copyright violation, legal request, or regulatory compliance issue?`,
)}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<View <View
style={[ style={[
@@ -381,7 +375,7 @@ function Inner(props: ReportDialogProps) {
<StepOuter> <StepOuter>
<StepTitle <StepTitle
index={2} index={2}
title={_(msg`Select a reason`)} title={l`Select a reason`}
activeIndex1={state.activeStepIndex1} activeIndex1={state.activeStepIndex1}
/> />
{state.selectedOption ? ( {state.selectedOption ? (
@@ -391,7 +385,7 @@ function Inner(props: ReportDialogProps) {
</View> </View>
<Button <Button
testID="report:clearReportOption" testID="report:clearReportOption"
label={_(msg`Change report reason`)} label={l`Change report reason`}
size="tiny" size="tiny"
variant="solid" variant="solid"
color="secondary" color="secondary"
@@ -431,7 +425,7 @@ function Inner(props: ReportDialogProps) {
<StepOuter> <StepOuter>
<StepTitle <StepTitle
index={3} index={3}
title={_(msg`Select moderation service`)} title={l`Select moderation service`}
activeIndex1={state.activeStepIndex1} activeIndex1={state.activeStepIndex1}
/> />
{state.activeStepIndex1 >= 3 && ( {state.activeStepIndex1 >= 3 && (
@@ -446,7 +440,7 @@ function Inner(props: ReportDialogProps) {
<LabelerCard labeler={state.selectedLabeler} /> <LabelerCard labeler={state.selectedLabeler} />
</View> </View>
<Button <Button
label={_(msg`Change moderation service`)} label={l`Change moderation service`}
size="tiny" size="tiny"
variant="solid" variant="solid"
color="secondary" color="secondary"
@@ -509,7 +503,7 @@ function Inner(props: ReportDialogProps) {
<StepOuter> <StepOuter>
<StepTitle <StepTitle
index={isAlwaysBskyLabeler ? 3 : 4} index={isAlwaysBskyLabeler ? 3 : 4}
title={_(msg`Submit report`)} title={l`Submit report`}
activeIndex1={ activeIndex1={
isAlwaysBskyLabeler isAlwaysBskyLabeler
? state.activeStepIndex1 - 1 ? state.activeStepIndex1 - 1
@@ -529,7 +523,7 @@ function Inner(props: ReportDialogProps) {
</Trans>{' '} </Trans>{' '}
{!state.detailsOpen ? ( {!state.detailsOpen ? (
<InlineLinkText <InlineLinkText
label={_(msg`Add more details (optional)`)} label={l`Add more details (optional)`}
{...createStaticClick(() => { {...createStaticClick(() => {
dispatch({type: 'showDetails'}) dispatch({type: 'showDetails'})
})}> })}>
@@ -547,7 +541,7 @@ function Inner(props: ReportDialogProps) {
onChangeText={details => { onChangeText={details => {
dispatch({type: 'setDetails', details}) dispatch({type: 'setDetails', details})
}} }}
label={_(msg`Additional details (limit 300 characters)`)} label={l`Additional details (limit 300 characters)`}
style={{paddingRight: 60}} style={{paddingRight: 60}}
numberOfLines={4} numberOfLines={4}
/> />
@@ -570,12 +564,12 @@ function Inner(props: ReportDialogProps) {
</View> </View>
<Button <Button
testID="report:submit" testID="report:submit"
label={_(msg`Submit report`)} label={l`Submit report`}
size="large" size="large"
variant="solid" variant="solid"
color="primary" color="primary"
disabled={isPending || isSuccess} disabled={isPending || isSuccess}
onPress={onSubmit}> onPress={() => void onSubmit()}>
<ButtonText> <ButtonText>
<Trans>Submit report</Trans> <Trans>Submit report</Trans>
</ButtonText> </ButtonText>
@@ -702,7 +696,7 @@ function CategoryCard({
onSelect?: (option: ReportCategoryConfig) => void onSelect?: (option: ReportCategoryConfig) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const gutters = useGutters(['compact']) const gutters = useGutters(['compact'])
const onPress = useCallback(() => { const onPress = useCallback(() => {
onSelect?.(option) onSelect?.(option)
@@ -710,7 +704,7 @@ function CategoryCard({
return ( return (
<Button <Button
testID={`report:category:${option.title}`} testID={`report:category:${option.title}`}
label={_(msg`Create report for ${option.title}`)} label={l`Create report for ${option.title}`}
onPress={onPress} onPress={onPress}
disabled={!onSelect}> disabled={!onSelect}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
@@ -747,7 +741,7 @@ function OptionCard({
onSelect?: (option: ReportOption) => void onSelect?: (option: ReportOption) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const gutters = useGutters(['compact']) const gutters = useGutters(['compact'])
const onPress = useCallback(() => { const onPress = useCallback(() => {
onSelect?.(option) onSelect?.(option)
@@ -755,13 +749,11 @@ function OptionCard({
return ( return (
<Button <Button
testID={`report:option:${option.title}`} testID={`report:option:${option.title}`}
label={_( label={l({
msg({ message: `Create report for ${option.title}`,
message: `Create report for ${option.title}`, comment:
comment: 'Accessibility label for button to create a moderation report for the selected option',
'Accessibility label for button to create a moderation report for the selected option', })}
}),
)}
onPress={onPress} onPress={onPress}
disabled={!onSelect}> disabled={!onSelect}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
@@ -810,7 +802,7 @@ function LabelerCard({
onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const onPress = useCallback(() => { const onPress = useCallback(() => {
onSelect?.(labeler) onSelect?.(labeler)
}, [onSelect, labeler]) }, [onSelect, labeler])
@@ -821,7 +813,7 @@ function LabelerCard({
return ( return (
<Button <Button
testID={`report:labeler:${labeler.creator.handle}`} testID={`report:labeler:${labeler.creator.handle}`}
label={_(msg`Send report to ${title}`)} label={l`Send report to ${title}`}
onPress={onPress} onPress={onPress}
disabled={!onSelect}> disabled={!onSelect}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
@@ -8,12 +8,17 @@ import {
import type * as Dialog from '#/components/Dialog' import type * as Dialog from '#/components/Dialog'
export type ReportSubjectConvo = { export type ReportSubjectConvoMessage = {
view: 'convo' | 'message' view: 'convo' | 'message'
convoId: string convoId: string
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
} }
export type ReportSubjectConvo = {
convoId: string
did: string
}
export type ReportSubject = export type ReportSubject =
| $Typed<AppBskyActorDefs.ProfileViewBasic> | $Typed<AppBskyActorDefs.ProfileViewBasic>
| $Typed<AppBskyActorDefs.ProfileView> | $Typed<AppBskyActorDefs.ProfileView>
@@ -23,6 +28,7 @@ export type ReportSubject =
| $Typed<AppBskyFeedDefs.GeneratorView> | $Typed<AppBskyFeedDefs.GeneratorView>
| $Typed<AppBskyGraphDefs.StarterPackView> | $Typed<AppBskyGraphDefs.StarterPackView>
| $Typed<AppBskyFeedDefs.PostView> | $Typed<AppBskyFeedDefs.PostView>
| ReportSubjectConvoMessage
| ReportSubjectConvo | ReportSubjectConvo
export type ParsedReportSubject = export type ParsedReportSubject =
@@ -70,6 +76,9 @@ export type ParsedReportSubject =
} }
| ({ | ({
type: 'convoMessage' type: 'convoMessage'
} & ReportSubjectConvoMessage)
| ({
type: 'convo'
} & ReportSubjectConvo) } & ReportSubjectConvo)
export type ReportDialogProps = { export type ReportDialogProps = {
@@ -17,9 +17,16 @@ export function parseReportSubject(
if (!subject) return if (!subject) return
if ('convoId' in subject) { if ('convoId' in subject) {
if ('message' in subject) {
return {
type: 'convoMessage',
...subject,
}
}
return { return {
type: 'convoMessage', type: 'convo',
...subject, convoId: subject.convoId,
did: subject.did,
} }
} }
@@ -31,6 +31,8 @@ import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles' import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog'
import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
import { import {
type ConvoWithDetails, type ConvoWithDetails,
type GroupConvoMember, type GroupConvoMember,
@@ -330,8 +332,7 @@ function SettingsHeader({
const {joinLink} = convo.details const {joinLink} = convo.details
const isJoinLinkEnabled = isOwner || joinLink?.enabledStatus === 'enabled' const isJoinLinkEnabled = isOwner || joinLink?.enabledStatus === 'enabled'
// TODO Enable this once the feature is working end-to-end. -dsb const reportSubjectDid = convo.primaryMember?.did
const isReportLinkEnabled = false
const {mutate: editGroupName, isPending: isEditingName} = const {mutate: editGroupName, isPending: isEditingName} =
useEditGroupChatName(convo.view.id, { useEditGroupChatName(convo.view.id, {
@@ -402,30 +403,8 @@ function SettingsHeader({
const editNamePrompt = Prompt.usePromptControl() const editNamePrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl()
const leaveChatPrompt = Prompt.usePromptControl() const leaveChatPrompt = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const handleToggleMute = () => { const deleteControl = Prompt.usePromptControl()
muteConvo({mute: !convo.view.muted})
}
// TODO Need to implement this when the backend is ready. -dsb
const handleReportChat = () => {}
const handlePromptName = () => {
setNewGroupName(groupName)
editNamePrompt.open()
}
const handleEditName = () => {
editGroupName({name: newGroupName})
}
const handleConfirmLock = () => {
lockConvo({lock: true})
}
const handleUnlock = () => {
lockConvo({lock: false})
}
const createdAt = new Date(convo.details.createdAt) const createdAt = new Date(convo.details.createdAt)
@@ -486,7 +465,7 @@ function SettingsHeader({
: l`Mute this group chat` : l`Mute this group chat`
} }
text={convo.view.muted ? l`Muted` : l`Mute`} text={convo.view.muted ? l`Muted` : l`Mute`}
onPress={handleToggleMute} onPress={() => muteConvo({mute: !convo.view.muted})}
/> />
{isOwner ? ( {isOwner ? (
<SettingsButton <SettingsButton
@@ -494,7 +473,10 @@ function SettingsHeader({
icon={EditIcon} icon={EditIcon}
label={l`Edit this group chats name`} label={l`Edit this group chats name`}
text={l`Edit name`} text={l`Edit name`}
onPress={handlePromptName} onPress={() => {
setNewGroupName(groupName)
editNamePrompt.open()
}}
/> />
) : null} ) : null}
{isJoinLinkEnabled ? ( {isJoinLinkEnabled ? (
@@ -522,20 +504,22 @@ function SettingsHeader({
} }
text={lockStatus === 'locked' ? l`Locked` : l`Lock`} text={lockStatus === 'locked' ? l`Locked` : l`Lock`}
onPress={ onPress={
lockStatus === 'locked' ? handleUnlock : lockChatPrompt.open lockStatus === 'locked'
? () => lockConvo({lock: false})
: lockChatPrompt.open
} }
/> />
) : null} ) : null}
{!isOwner && isReportLinkEnabled && ( {!isOwner && reportSubjectDid ? (
<SettingsButton <SettingsButton
disabled={!isReady} disabled={!isReady}
icon={FlagIcon} icon={FlagIcon}
label={l`Report this group chat`} label={l`Report this group chat`}
text={l`Report`} text={l`Report`}
onPress={handleReportChat} onPress={reportControl.open}
/> />
)} ) : null}
{!isOwner && ( {!isOwner ? (
<SettingsButton <SettingsButton
disabled={!isReady || isLeaving} disabled={!isReady || isLeaving}
icon={ArrowBoxLeftIcon} icon={ArrowBoxLeftIcon}
@@ -543,14 +527,14 @@ function SettingsHeader({
text={l`Leave`} text={l`Leave`}
onPress={leaveChatPrompt.open} onPress={leaveChatPrompt.open}
/> />
)} ) : null}
</View> </View>
</View> </View>
<EditNamePrompt <EditNamePrompt
control={editNamePrompt} control={editNamePrompt}
value={newGroupName} value={newGroupName}
onChangeText={setNewGroupName} onChangeText={setNewGroupName}
onConfirm={handleEditName} onConfirm={() => editGroupName({name: newGroupName})}
/> />
{convo.primaryMember && ( {convo.primaryMember && (
<InviteLinkDialog <InviteLinkDialog
@@ -561,12 +545,33 @@ function SettingsHeader({
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
)} )}
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> <LockChatPrompt
control={lockChatPrompt}
onConfirm={() => lockConvo({lock: true})}
/>
<LeaveChatPrompt <LeaveChatPrompt
control={leaveChatPrompt} control={leaveChatPrompt}
groupName={groupName} groupName={groupName}
onConfirm={leaveConvo} onConfirm={leaveConvo}
/> />
{reportSubjectDid ? (
<>
<ReportConversationDialog
control={reportControl}
convoId={convo.view.id}
did={reportSubjectDid}
onAfterSubmit={deleteControl.open}
/>
<AfterReportConversationDialog
control={deleteControl}
currentScreen="conversation"
params={{
convoId: convo.view.id,
did: reportSubjectDid,
}}
/>
</>
) : null}
</> </>
) )
} }
@@ -192,7 +192,7 @@ export function RejectMenu({
currentScreen={currentScreen} currentScreen={currentScreen}
params={{ params={{
convoId: convo.id, convoId: convo.id,
message: lastMessage, did: lastMessage.sender.did,
}} }}
/> />
</> </>