Post-report menu (#7446)

* post-report block/delete dialog

* fix

* default checked

* web styles

* add icon to send button

* wire everything up

* optimisically leave convo

* hide pending-leave convos

* Capitalize action labels

* Code style

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
This commit is contained in:
Samuel Newman
2025-01-22 21:03:31 +00:00
committed by GitHub
parent 638008c781
commit 74bb65714b
7 changed files with 218 additions and 33 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types' import {ReportDialogProps} from './types'
@@ -223,7 +224,7 @@ export function SubmitView({
<ButtonText> <ButtonText>
<Trans>Send report</Trans> <Trans>Send report</Trans>
</ButtonText> </ButtonText>
{submitting && <ButtonIcon icon={Loader} />} <ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button> </Button>
</View> </View>
{/* Maybe fix this later -h */} {/* Maybe fix this later -h */}
+1
View File
@@ -227,6 +227,7 @@ let ConvoMenu = ({
/> />
{latestReportableMessage ? ( {latestReportableMessage ? (
<ReportDialog <ReportDialog
currentScreen={currentScreen}
params={{ params={{
type: 'convoMessage', type: 'convoMessage',
convoId: convo.id, convoId: convo.id,
+4 -9
View File
@@ -1,6 +1,6 @@
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
@@ -22,15 +22,10 @@ export function LeaveConvoPrompt({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {mutate: leaveConvo} = useLeaveConvo(convoId, { const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onSuccess: () => { onMutate: () => {
if (currentScreen === 'conversation') { if (currentScreen === 'conversation') {
navigation.replace( navigation.dispatch(
'Messages', StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}),
isNative
? {
animation: 'pop',
}
: {},
) )
} }
}, },
+1
View File
@@ -138,6 +138,7 @@ export let MessageMenu = ({
</Menu.Root> </Menu.Root>
<ReportDialog <ReportDialog
currentScreen="conversation"
params={{type: 'convoMessage', convoId: convo.convo.id, message}} params={{type: 'convoMessage', convoId: convo.convo.id, message}}
control={reportControl} control={reportControl}
/> />
+169 -20
View File
@@ -1,27 +1,39 @@
import React, {memo, useMemo, useState} from 'react' import React, {memo, useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
AppBskyActorDefs,
ChatBskyConvoDefs, ChatBskyConvoDefs,
ComAtprotoModerationCreateReport, ComAtprotoModerationCreateReport,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {NavigationProp} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useProfileBlockMutationQueue,
useProfileQuery,
} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Button, ButtonIcon, ButtonText} from '../Button' import {Divider} from '#/components/Divider'
import {Divider} from '../Divider' import * as Toggle from '#/components/forms/Toggle'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron' import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {Loader} from '../Loader' import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane'
import {SelectReportOptionView} from '../ReportDialog/SelectReportOptionView' import {Loader} from '#/components/Loader'
import {RichText} from '../RichText' import {SelectReportOptionView} from '#/components/ReportDialog/SelectReportOptionView'
import {Text} from '../Typography' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {MessageItemMetadata} from './MessageItem' import {MessageItemMetadata} from './MessageItem'
type ReportDialogParams = { type ReportDialogParams = {
@@ -33,16 +45,18 @@ type ReportDialogParams = {
let ReportDialog = ({ let ReportDialog = ({
control, control,
params, params,
currentScreen,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
params: ReportDialogParams params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}> <Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} /> <DialogInner params={params} currentScreen={currentScreen} />
<Dialog.Close /> <Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
@@ -51,14 +65,44 @@ let ReportDialog = ({
ReportDialog = memo(ReportDialog) ReportDialog = memo(ReportDialog)
export {ReportDialog} export {ReportDialog}
function DialogInner({params}: {params: ReportDialogParams}) { function DialogInner({
params,
currentScreen,
}: {
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
}) {
const {data: profile, isError} = useProfileQuery({
did: params.message.sender.did,
})
const [reportOption, setReportOption] = useState<ReportOption | null>(null) const [reportOption, setReportOption] = useState<ReportOption | null>(null)
const [done, setDone] = useState(false)
const control = Dialog.useDialogContext()
return reportOption ? ( return done ? (
profile ? (
<DoneStep
convoId={params.convoId}
currentScreen={currentScreen}
profile={profile}
/>
) : (
<View style={[a.w_full, a.py_5xl, a.align_center]}>
<Loader />
</View>
)
) : reportOption ? (
<SubmitStep <SubmitStep
params={params} params={params}
reportOption={reportOption} reportOption={reportOption}
goBack={() => setReportOption(null)} goBack={() => setReportOption(null)}
onComplete={() => {
if (isError) {
control.close()
} else {
setDone(true)
}
}}
/> />
) : ( ) : (
<ReasonStep params={params} setReportOption={setReportOption} /> <ReasonStep params={params} setReportOption={setReportOption} />
@@ -89,16 +133,17 @@ function SubmitStep({
params, params,
reportOption, reportOption,
goBack, goBack,
onComplete,
}: { }: {
params: ReportDialogParams params: ReportDialogParams
reportOption: ReportOption reportOption: ReportOption
goBack: () => void goBack: () => void
onComplete: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const t = useTheme() const t = useTheme()
const [details, setDetails] = useState('') const [details, setDetails] = useState('')
const control = Dialog.useDialogContext()
const agent = useAgent() const agent = useAgent()
const { const {
@@ -124,11 +169,7 @@ function SubmitStep({
await agent.createModerationReport(report) await agent.createModerationReport(report)
} }
}, },
onSuccess: () => { onSuccess: onComplete,
control.close(() => {
Toast.show(_(msg`Thank you. Your report has been sent.`))
})
},
}) })
const copy = useMemo(() => { const copy = useMemo(() => {
@@ -181,11 +222,11 @@ function SubmitStep({
<View style={[a.relative, a.w_full]}> <View style={[a.relative, a.w_full]}>
<Dialog.Input <Dialog.Input
multiline multiline
value={details} defaultValue={details}
onChangeText={setDetails} onChangeText={setDetails}
label="Text field" label="Text field"
style={{paddingRight: 60}} style={{paddingRight: 60}}
numberOfLines={6} numberOfLines={5}
/> />
<View <View
@@ -231,7 +272,115 @@ function SubmitStep({
<ButtonText> <ButtonText>
<Trans>Send report</Trans> <Trans>Send report</Trans>
</ButtonText> </ButtonText>
{submitting && <ButtonIcon icon={Loader} />} <ButtonIcon icon={submitting ? Loader : SendIcon} />
</Button>
</View>
</View>
)
}
function DoneStep({
convoId,
currentScreen,
profile,
}: {
convoId: string
currentScreen: 'list' | 'conversation'
profile: AppBskyActorDefs.ProfileViewBasic
}) {
const {_} = 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 {mutate: leaveConvo} = useLeaveConvo(convoId, {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.dispatch(
StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}),
)
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`), 'xmark')
},
})
const onPressPrimaryAction = () => {
control.close(() => {
if (actions.includes('block')) {
queueBlock()
}
if (actions.includes('leave')) {
leaveConvo()
}
})
}
let btnText = _(msg`Done`)
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
}
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_bold]}>
<Trans>Report submitted</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Our moderation team has recieved your report.</Trans>
</Text>
</View>
<Toggle.Group
label={_(msg`Block and/or delete this conversation`)}
values={actions}
onChange={setActions}>
<View style={[a.gap_md]}>
<Toggle.Item name="block" label={_(msg`Block user`)}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Block user</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item name="leave" label={_(msg`Delete coversation`)}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.text_md]}>
<Trans>Delete conversation</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
<View style={[a.gap_md, web([a.flex_row_reverse])]}>
<Button
label={btnText}
onPress={onPressPrimaryAction}
size="large"
variant="solid"
color={actions.length > 0 ? 'negative' : 'primary'}>
<ButtonText>{btnText}</ButtonText>
</Button>
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
size={platform({native: 'small', web: 'large'})}
variant={platform({
native: 'ghost',
web: 'solid',
})}
color="secondary">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button> </Button>
</View> </View>
</View> </View>
+10 -2
View File
@@ -14,6 +14,7 @@ import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -94,12 +95,19 @@ export function MessagesScreen({navigation, route}: Props) {
useRefreshOnFocus(refetch) useRefreshOnFocus(refetch)
const leftConvos = useLeftConvos()
const conversations = useMemo(() => { const conversations = useMemo(() => {
if (data?.pages) { if (data?.pages) {
return data.pages.flatMap(page => page.convos) return (
data.pages
.flatMap(page => page.convos)
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
)
} }
return [] return []
}, [data]) }, [data, leftConvos])
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
@@ -1,17 +1,28 @@
import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api' import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {
useMutation,
useMutationState,
useQueryClient,
} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations' import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
const RQKEY_ROOT = 'leave-convo'
export function RQKEY(convoId: string | undefined) {
return [RQKEY_ROOT, convoId]
}
export function useLeaveConvo( export function useLeaveConvo(
convoId: string | undefined, convoId: string | undefined,
{ {
onSuccess, onSuccess,
onMutate,
onError, onError,
}: { }: {
onMutate?: () => void
onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
onError?: (error: Error) => void onError?: (error: Error) => void
}, },
@@ -20,6 +31,7 @@ export function useLeaveConvo(
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
mutationKey: RQKEY(convoId),
mutationFn: async () => { mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided') if (!convoId) throw new Error('No convoId provided')
@@ -51,6 +63,7 @@ export function useLeaveConvo(
} }
}, },
) )
onMutate?.()
return {prevPages} return {prevPages}
}, },
onSuccess: data => { onSuccess: data => {
@@ -77,3 +90,20 @@ export function useLeaveConvo(
}, },
}) })
} }
/**
* Gets currently pending and successful leave convo mutations
*
* @returns Array of `convoId`
*/
export function useLeftConvos() {
const pending = useMutationState({
filters: {mutationKey: [RQKEY_ROOT], status: 'pending'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
})
const success = useMutationState({
filters: {mutationKey: [RQKEY_ROOT], status: 'success'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
})
return [...pending, ...success].filter(id => id !== undefined)
}