Update presentation of chat requests (#10417)

This commit is contained in:
DS Boyce
2026-05-08 12:05:05 -07:00
committed by GitHub
parent c8e4815837
commit a889a1b081
5 changed files with 160 additions and 102 deletions
+6 -9
View File
@@ -5,9 +5,7 @@ import {
moderateProfile, moderateProfile,
type ModerationOpts, type ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -93,7 +91,7 @@ function KnownFollowersInner({
showIfEmpty?: boolean showIfEmpty?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium] const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
@@ -124,9 +122,7 @@ function KnownFollowersInner({
return ( return (
<Link <Link
label={_( label={l`Press to view followers of this account that you also follow`}
msg`Press to view followers of this account that you also follow`,
)}
onPress={onLinkPress} onPress={onLinkPress}
to={makeProfileLink(profile, 'known-followers')} to={makeProfileLink(profile, 'known-followers')}
style={[ style={[
@@ -188,7 +184,8 @@ function KnownFollowersInner({
numberOfLines={2}> numberOfLines={2}>
{slice.length >= 2 ? ( {slice.length >= 2 ? (
// 2-n followers, including blocks // 2-n followers, including blocks
serverCount > 2 ? ( // only 2 // only 2
serverCount > 2 ? (
<Trans> <Trans>
Followed by{' '} Followed by{' '}
<Text emoji key={slice[0].profile.did} style={textStyle}> <Text emoji key={slice[0].profile.did} style={textStyle}>
@@ -254,7 +251,7 @@ function EmptyFallback({show}: {show?: boolean}) {
return ( return (
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>Not followed by anyone you're following</Trans> <Trans>Not followed by anyone youre following</Trans>
</Text> </Text>
) )
} }
+7 -10
View File
@@ -1,5 +1,4 @@
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
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'
@@ -20,7 +19,7 @@ export function LeaveConvoPrompt({
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
hasMessages?: boolean hasMessages?: boolean
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {mutate: leaveConvo} = useLeaveConvo(convoId, { const {mutate: leaveConvo} = useLeaveConvo(convoId, {
@@ -32,7 +31,7 @@ export function LeaveConvoPrompt({
} }
}, },
onError: () => { onError: () => {
Toast.show(_(msg`Could not leave chat`), { Toast.show(l`Could not leave chat`, {
type: 'error', type: 'error',
}) })
}, },
@@ -41,15 +40,13 @@ export function LeaveConvoPrompt({
return ( return (
<Prompt.Basic <Prompt.Basic
control={control} control={control}
title={_(msg`Leave conversation`)} title={l`Leave conversation`}
description={ description={
hasMessages hasMessages
? _( ? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, : l`Are you sure you want to leave this conversation?`
)
: _(msg`Are you sure you want to leave this conversation?`)
} }
confirmButtonCta={_(msg`Leave`)} confirmButtonCta={l`Leave`}
confirmButtonColor="negative" confirmButtonColor="negative"
onConfirm={() => leaveConvo()} onConfirm={() => leaveConvo()}
/> />
@@ -1,19 +1,26 @@
import {useCallback} from 'react' import {useCallback, useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {LinearGradient} from 'expo-linear-gradient'
import {useLingui} from '@lingui/react' import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type ActiveConvoStates} from '#/state/messages/convo' import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {KnownFollowers} from '#/components/KnownFollowers' import {KnownFollowers} from '#/components/KnownFollowers'
import {usePromptControl} from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const leaveConvoControl = usePromptControl() const leaveConvoControl = usePromptControl()
@@ -25,36 +32,59 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
// if we ever allow someone other than the owner to invite people, this will need to change // if we ever allow someone other than the owner to invite people, this will need to change
const otherUser = convoState.convo.primaryMember const otherUser = convoState.convo.primaryMember
const lastMessage = ChatBskyConvoDefs.isMessageView(
convoState.convo.view.lastMessage,
)
? convoState.convo.view.lastMessage
: null
if (!moderationOpts) { if (!moderationOpts) {
return null return null
} }
return ( return (
<View style={[t.atoms.bg, a.p_lg, a.gap_md, a.align_center]}> <View style={[a.gap_md, a.p_2xl, t.atoms.bg]}>
<LinearGradient
colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.08)']}
style={[a.absolute, {top: -16, left: 0, right: 0, height: 16}]}
pointerEvents="none"
/>
{otherUser && (
<InviterHeader profile={otherUser} moderationOpts={moderationOpts} />
)}
{otherUser && ( {otherUser && (
<KnownFollowers <KnownFollowers
profile={otherUser} profile={otherUser}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
minimal
showIfEmpty showIfEmpty
/> />
)} )}
<View style={[a.flex_row, a.gap_md, a.w_full, otherUser && a.pt_sm]}> <View style={[a.flex_row, a.gap_md, a.w_full, otherUser && a.pt_sm]}>
{otherUser && ( {otherUser && (
<RejectMenu <RejectMenu
label={_(msg`Block or report`)} label={lastMessage ? l`Block or report` : l`Block`}
icon={true}
convo={convoState.convo.view} convo={convoState.convo.view}
profile={otherUser} profile={otherUser}
color="negative_subtle" color="negative_subtle"
size="small" size="large"
currentScreen="conversation" currentScreen="conversation"
style={[a.flex_1]}
/> />
)} )}
<DeleteChatButton <DeleteChatButton
label={_(msg`Delete`)} label={l({
message: 'Leave',
comment: 'Leave a conversation (reject a chat invitation)',
context: 'Button',
})}
icon={true}
convo={convoState.convo.view} convo={convoState.convo.view}
color="secondary" color="secondary"
size="small" size="large"
currentScreen="conversation" currentScreen="conversation"
style={[a.flex_1]}
onPress={leaveConvoControl.open} onPress={leaveConvoControl.open}
/> />
<LeaveConvoPrompt <LeaveConvoPrompt
@@ -66,13 +96,53 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
</View> </View>
<View style={[a.w_full, a.flex_row]}> <View style={[a.w_full, a.flex_row]}>
<AcceptChatButton <AcceptChatButton
icon={true}
onAcceptConvo={onAcceptChat} onAcceptConvo={onAcceptChat}
convo={convoState.convo.view} convo={convoState.convo.view}
color="primary_subtle" color="primary"
size="small" size="large"
currentScreen="conversation" currentScreen="conversation"
style={[a.flex_1]}
/> />
</View> </View>
</View> </View>
) )
} }
function InviterHeader({
profile: profileUnshadowed,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: NonNullable<ReturnType<typeof useModerationOpts>>
}) {
const t = useTheme()
const profile = useProfileShadow(profileUnshadowed)
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const displayName = createSanitizedDisplayName(
profile,
true,
moderation.ui('displayName'),
)
return (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<PreviewableUserAvatar
profile={profile}
size={42}
moderation={moderation.ui('avatar')}
/>
<View>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
<Trans>{displayName} added you</Trans>
</Text>
<Text style={[web(a.pt_xs), a.text_sm, t.atoms.text_contrast_high]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</View>
)
}
@@ -1,8 +1,6 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyActorDefs, ChatBskyConvoDefs} 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 {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -16,7 +14,6 @@ import {
unstableCacheProfileView, unstableCacheProfileView,
useProfileBlockMutationQueue, useProfileBlockMutationQueue,
} from '#/state/queries/profile' } from '#/state/queries/profile'
import {atoms as a} from '#/alf'
import { import {
Button, Button,
ButtonIcon, ButtonIcon,
@@ -29,6 +26,8 @@ import {
useEmailDialogControl, useEmailDialogControl,
} from '#/components/dialogs/EmailDialog' } from '#/components/dialogs/EmailDialog'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
@@ -40,20 +39,22 @@ import * as Toast from '#/components/Toast'
export function RejectMenu({ export function RejectMenu({
convo, convo,
profile, profile,
size = 'tiny', size = 'small',
color = 'secondary', color = 'secondary',
label, label,
icon = false,
showDeleteConvo, showDeleteConvo,
currentScreen, currentScreen,
...props ...props
}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & { }: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & {
label?: string label?: string
icon?: boolean
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
profile: ChatBskyActorDefs.ProfileViewBasic profile: ChatBskyActorDefs.ProfileViewBasic
showDeleteConvo?: boolean showDeleteConvo?: boolean
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const shadowedProfile = useProfileShadow(profile) const shadowedProfile = useProfileShadow(profile)
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -66,12 +67,10 @@ export function RejectMenu({
}, },
onError: () => { onError: () => {
Toast.show( Toast.show(
_( l({
msg({
context: 'toast', context: 'toast',
message: 'Failed to delete chat', message: 'Failed to delete chat',
}), }),
),
{ {
type: 'error', type: 'error',
}, },
@@ -82,35 +81,31 @@ export function RejectMenu({
const onPressDelete = useCallback(() => { const onPressDelete = useCallback(() => {
Toast.show( Toast.show(
_( l({
msg({
context: 'toast', context: 'toast',
message: 'Chat deleted', message: 'Chat deleted',
}), }),
),
{ {
type: 'success', type: 'success',
}, },
) )
leaveConvo() leaveConvo()
}, [leaveConvo, _]) }, [leaveConvo, l])
const onPressBlock = useCallback(() => { const onPressBlock = useCallback(() => {
Toast.show( Toast.show(
_( l({
msg({
context: 'toast', context: 'toast',
message: 'Account blocked', message: 'Account blocked',
}), }),
),
{ {
type: 'success', type: 'success',
}, },
) )
// block and also delete convo // block and also delete convo
queueBlock() void queueBlock()
leaveConvo() leaveConvo()
}, [queueBlock, leaveConvo, _]) }, [queueBlock, leaveConvo, l])
const reportControl = useDialogControl() const reportControl = useDialogControl()
const blockOrDeleteControl = useDialogControl() const blockOrDeleteControl = useDialogControl()
@@ -122,15 +117,15 @@ export function RejectMenu({
return ( return (
<> <>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`Reject chat request`)}> <Menu.Trigger label={l`Reject chat request`}>
{({props: triggerProps}) => ( {({props: triggerProps}) => (
<Button <Button
{...triggerProps} {...triggerProps}
{...props} {...props}
label={triggerProps.accessibilityLabel} label={triggerProps.accessibilityLabel}
style={[a.flex_1]}
color={color} color={color}
size={size}> size={size}>
{icon ? <ButtonIcon icon={FlagIcon} /> : null}
<ButtonText> <ButtonText>
{label || ( {label || (
<Trans comment="Reject a chat request, this opens a menu with options"> <Trans comment="Reject a chat request, this opens a menu with options">
@@ -144,16 +139,14 @@ export function RejectMenu({
<Menu.Outer showCancel> <Menu.Outer showCancel>
<Menu.Group> <Menu.Group>
{showDeleteConvo && ( {showDeleteConvo && (
<Menu.Item <Menu.Item label={l`Delete conversation`} onPress={onPressDelete}>
label={_(msg`Delete conversation`)}
onPress={onPressDelete}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Delete conversation</Trans> <Trans>Delete conversation</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={CircleX_Stroke2_Corner0_Rounded} /> <Menu.ItemIcon icon={CircleX_Stroke2_Corner0_Rounded} />
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item label={_(msg`Block account`)} onPress={onPressBlock}> <Menu.Item label={l`Block account`} onPress={onPressBlock}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Block account</Trans> <Trans>Block account</Trans>
</Menu.ItemText> </Menu.ItemText>
@@ -164,7 +157,7 @@ export function RejectMenu({
screen to have a message sent by you */} screen to have a message sent by you */}
{lastMessage && ( {lastMessage && (
<Menu.Item <Menu.Item
label={_(msg`Report conversation`)} label={l`Report conversation`}
onPress={reportControl.open}> onPress={reportControl.open}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Report conversation</Trans> <Trans>Report conversation</Trans>
@@ -210,19 +203,21 @@ export function RejectMenu({
export function AcceptChatButton({ export function AcceptChatButton({
convo, convo,
size = 'tiny', size = 'small',
color = 'secondary_inverted', color = 'primary',
label, label,
icon = false,
currentScreen, currentScreen,
onAcceptConvo, onAcceptConvo,
...props ...props
}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & { }: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & {
label?: string label?: string
icon?: boolean
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
onAcceptConvo?: () => void onAcceptConvo?: () => void
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
@@ -245,12 +240,10 @@ export function AcceptChatButton({
// automatically. The only difference is that when they back out of the convo (without sending a message), the conversation will be rejected. // automatically. The only difference is that when they back out of the convo (without sending a message), the conversation will be rejected.
// the list will still have this chat in it -sfn // the list will still have this chat in it -sfn
Toast.show( Toast.show(
_( l({
msg({
context: 'toast', context: 'toast',
message: 'Failed to accept chat', message: 'Failed to accept chat',
}), }),
),
{ {
type: 'error', type: 'error',
}, },
@@ -274,38 +267,43 @@ export function AcceptChatButton({
} }
}, [acceptConvo, needsEmailVerification, emailDialogControl]) }, [acceptConvo, needsEmailVerification, emailDialogControl])
let Icon: React.ReactNode = null
if (isPending) {
Icon = <ButtonIcon icon={Loader} />
} else if (icon) {
Icon = <ButtonIcon icon={CheckIcon} />
}
return ( return (
<Button <Button
{...props} {...props}
label={label || _(msg`Accept chat request`)} label={label || l`Accept chat request`}
size={size} size={size}
color={color} color={color}
style={a.flex_1}
onPress={onPressAccept}> onPress={onPressAccept}>
{isPending ? ( {Icon}
<ButtonIcon icon={Loader} />
) : (
<ButtonText> <ButtonText>
{label || <Trans comment="Accept a chat request">Accept</Trans>} {label || <Trans comment="Accept a chat request">Accept</Trans>}
</ButtonText> </ButtonText>
)}
</Button> </Button>
) )
} }
export function DeleteChatButton({ export function DeleteChatButton({
convo, convo,
size = 'tiny', size = 'small',
color = 'secondary', color = 'secondary',
label, label,
icon = false,
currentScreen, currentScreen,
...props ...props
}: Omit<ButtonProps, 'children' | 'label'> & { }: Omit<ButtonProps, 'children' | 'label'> & {
label?: string label?: string
icon?: boolean
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {mutate: leaveConvo} = useLeaveConvo(convo.id, { const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
@@ -316,12 +314,10 @@ export function DeleteChatButton({
}, },
onError: () => { onError: () => {
Toast.show( Toast.show(
_( l({
msg({
context: 'toast', context: 'toast',
message: 'Failed to delete chat', message: 'Failed to delete chat',
}), }),
),
{ {
type: 'error', type: 'error',
}, },
@@ -331,27 +327,25 @@ export function DeleteChatButton({
const onPressDelete = useCallback(() => { const onPressDelete = useCallback(() => {
Toast.show( Toast.show(
_( l({
msg({
context: 'toast', context: 'toast',
message: 'Chat deleted', message: 'Chat deleted',
}), }),
),
{ {
type: 'success', type: 'success',
}, },
) )
leaveConvo() leaveConvo()
}, [leaveConvo, _]) }, [leaveConvo, l])
return ( return (
<Button <Button
label={label || _(msg`Delete chat`)} label={label || l`Delete chat`}
size={size} size={size}
color={color} color={color}
style={a.flex_1}
onPress={onPressDelete} onPress={onPressDelete}
{...props}> {...props}>
{icon ? <ButtonIcon icon={LeaveIcon} /> : null}
<ButtonText>{label || <Trans>Delete chat</Trans>}</ButtonText> <ButtonText>{label || <Trans>Delete chat</Trans>}</ButtonText>
</Button> </Button>
) )
@@ -42,7 +42,7 @@ export function RequestListItem({
</View> </View>
)} )}
{/* spacer, since you can't nest pressables */} {/* spacer, since you can't nest pressables */}
<View style={[a.pt_md, a.pb_xs, a.w_full, {opacity: 0}]} aria-hidden> <View style={[a.py_lg, a.w_full, {opacity: 0}]} aria-hidden>
{/* Placeholder text so that it responds to the font height */} {/* Placeholder text so that it responds to the font height */}
<Text style={[a.text_xs, a.leading_tight, a.font_semi_bold]}> <Text style={[a.text_xs, a.leading_tight, a.font_semi_bold]}>
<Trans comment="Accept a chat request">Accept Request</Trans> <Trans comment="Accept a chat request">Accept Request</Trans>