diff options
Diffstat (limited to 'src/screens/Messages/components')
-rw-r--r-- | src/screens/Messages/components/ChatListItem.tsx | 65 | ||||
-rw-r--r-- | src/screens/Messages/components/ChatStatusInfo.tsx | 81 | ||||
-rw-r--r-- | src/screens/Messages/components/InboxPreview.tsx | 73 | ||||
-rw-r--r-- | src/screens/Messages/components/MessagesList.tsx | 68 | ||||
-rw-r--r-- | src/screens/Messages/components/RequestButtons.tsx | 254 | ||||
-rw-r--r-- | src/screens/Messages/components/RequestListItem.tsx | 78 |
6 files changed, 572 insertions, 47 deletions
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 501ab2374..96e010b8f 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -47,8 +47,12 @@ import * as bsky from '#/types/bsky' export let ChatListItem = ({ convo, + showMenu = true, + children, }: { convo: ChatBskyConvoDefs.ConvoView + showMenu?: boolean + children?: React.ReactNode }): React.ReactNode => { const {currentAccount} = useSession() const moderationOpts = useModerationOpts() @@ -66,7 +70,9 @@ export let ChatListItem = ({ convo={convo} profile={otherUser} moderationOpts={moderationOpts} - /> + showMenu={showMenu}> + {children} + </ChatListItemReady> ) } @@ -76,10 +82,14 @@ function ChatListItemReady({ convo, profile: profileUnshadowed, moderationOpts, + showMenu, + children, }: { convo: ChatBskyConvoDefs.ConvoView profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts + showMenu?: boolean + children?: React.ReactNode }) { const t = useTheme() const {_} = useLingui() @@ -252,6 +262,8 @@ function ChatListItemReady({ leftFirst: deleteAction, } + const hasUnread = convo.unreadCount > 0 && !isDeletedAccount + return ( <GestureActionView actions={actions}> <View @@ -305,7 +317,6 @@ function ChatListItemReady({ a.py_md, a.gap_md, (hovered || pressed || focused) && t.atoms.bg_contrast_25, - t.atoms.border_contrast_low, ]}> {/* Avatar goes here */} <View style={{width: 52, height: 52}} /> @@ -376,9 +387,7 @@ function ChatListItemReady({ style={[ a.text_sm, a.leading_snug, - convo.unreadCount > 0 - ? a.font_bold - : t.atoms.text_contrast_high, + hasUnread ? a.font_bold : t.atoms.text_contrast_high, isDimStyle && t.atoms.text_contrast_medium, ]}> {lastMessage} @@ -389,9 +398,11 @@ function ChatListItemReady({ size="lg" style={[a.pt_xs]} /> + + {children} </View> - {convo.unreadCount > 0 && ( + {hasUnread && ( <View style={[ a.absolute, @@ -412,26 +423,28 @@ function ChatListItemReady({ )} </Link> - <ConvoMenu - convo={convo} - profile={profile} - control={menuControl} - currentScreen="list" - showMarkAsRead={convo.unreadCount > 0} - hideTrigger={isNative} - blockInfo={blockInfo} - style={[ - a.absolute, - a.h_full, - a.self_end, - a.justify_center, - { - right: tokens.space.lg, - opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, - }, - ]} - latestReportableMessage={latestReportableMessage} - /> + {showMenu && ( + <ConvoMenu + convo={convo} + profile={profile} + control={menuControl} + currentScreen="list" + showMarkAsRead={convo.unreadCount > 0} + hideTrigger={isNative} + blockInfo={blockInfo} + style={[ + a.absolute, + a.h_full, + a.self_end, + a.justify_center, + { + right: tokens.space.lg, + opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, + }, + ]} + latestReportableMessage={latestReportableMessage} + /> + )} <LeaveConvoPrompt control={leaveConvoControl} convoId={convo.id} diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx new file mode 100644 index 000000000..a74f3092b --- /dev/null +++ b/src/screens/Messages/components/ChatStatusInfo.tsx @@ -0,0 +1,81 @@ +import {useCallback} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ActiveConvoStates} from '#/state/messages/convo' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' +import {KnownFollowers} from '#/components/KnownFollowers' +import {usePromptControl} from '#/components/Prompt' +import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' + +export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const {currentAccount} = useSession() + const leaveConvoControl = usePromptControl() + + const onAcceptChat = useCallback(() => { + convoState.markConvoAccepted() + }, [convoState]) + + const otherUser = convoState.recipients.find( + user => user.did !== currentAccount?.did, + ) + + if (!moderationOpts) { + return null + } + + return ( + <View style={[t.atoms.bg, a.p_lg, a.gap_md, a.align_center]}> + {otherUser && ( + <KnownFollowers + profile={otherUser} + moderationOpts={moderationOpts} + showIfEmpty + /> + )} + <View style={[a.flex_row, a.gap_md, a.w_full, otherUser && a.pt_sm]}> + {otherUser && ( + <RejectMenu + label={_(msg`Block or report`)} + convo={convoState.convo} + profile={otherUser} + color="negative" + size="small" + currentScreen="conversation" + /> + )} + <DeleteChatButton + label={_(msg`Delete`)} + convo={convoState.convo} + color="secondary" + size="small" + currentScreen="conversation" + onPress={leaveConvoControl.open} + /> + <LeaveConvoPrompt + convoId={convoState.convo.id} + control={leaveConvoControl} + currentScreen="conversation" + hasMessages={false} + /> + </View> + <View style={[a.w_full, a.flex_row]}> + <AcceptChatButton + onAcceptConvo={onAcceptChat} + convo={convoState.convo} + color="primary" + variant="outline" + size="small" + currentScreen="conversation" + /> + </View> + </View> + ) +} diff --git a/src/screens/Messages/components/InboxPreview.tsx b/src/screens/Messages/components/InboxPreview.tsx new file mode 100644 index 000000000..fe2803522 --- /dev/null +++ b/src/screens/Messages/components/InboxPreview.tsx @@ -0,0 +1,73 @@ +import {View} from 'react-native' +import {ChatBskyActorDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {AvatarStack} from '#/components/AvatarStack' +import {ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {Link} from '#/components/Link' + +export function InboxPreview({ + profiles, +}: // count, +{ + profiles: ChatBskyActorDefs.ProfileViewBasic[] + count: number +}) { + const {_} = useLingui() + const t = useTheme() + return ( + <Link + label={_(msg`Chat request inbox`)} + style={[ + a.flex_1, + a.px_xl, + a.py_sm, + a.flex_row, + a.align_center, + a.gap_md, + a.border_t, + {marginTop: a.border_t.borderTopWidth * -1}, + a.border_b, + t.atoms.border_contrast_low, + {minHeight: 44}, + a.rounded_0, + ]} + to="/messages/inbox" + color="secondary" + variant="solid"> + <View style={[a.relative]}> + <ButtonIcon icon={EnvelopeIcon} size="lg" /> + {profiles.length > 0 && ( + <View + style={[ + a.absolute, + a.rounded_full, + a.z_20, + { + top: -4, + right: -5, + width: 10, + height: 10, + backgroundColor: t.palette.primary_500, + }, + ]} + /> + )} + </View> + <ButtonText + style={[a.flex_1, a.font_bold, a.text_left]} + numberOfLines={1}> + <Trans>Chat requests</Trans> + </ButtonText> + <AvatarStack + profiles={profiles} + backgroundColor={t.atoms.bg_contrast_25.backgroundColor} + /> + <ButtonIcon icon={ArrowRightIcon} size="lg" /> + </Link> + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 10a2b1d37..e84a18a6b 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -9,7 +9,6 @@ import Animated, { useSharedValue, } from 'react-native-reanimated' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' -import {useSafeAreaInsets} from 'react-native-safe-area-context' import { $Typed, AppBskyEmbedRecord, @@ -17,7 +16,6 @@ import { RichText, } from '@atproto/api' -import {clamp} from '#/lib/numbers' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { @@ -31,6 +29,7 @@ import {isConvoActive, useConvoActive} from '#/state/messages/convo' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' +import {useShellLayout} from '#/state/shell/shell-layout' import { EmojiPicker, EmojiPickerState, @@ -44,6 +43,7 @@ import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {ChatStatusInfo} from './ChatStatusInfo' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' function MaybeLoader({isLoading}: {isLoading: boolean}) { @@ -85,11 +85,13 @@ export function MessagesList({ setHasScrolled, blocked, footer, + hasAcceptOverride, }: { hasScrolled: boolean setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> blocked?: boolean footer?: React.ReactNode + hasAcceptOverride?: boolean }) { const convoState = useConvoActive() const agent = useAgent() @@ -242,8 +244,7 @@ export function MessagesList({ ) // -- Keyboard animation handling - const {bottom: bottomInset} = useSafeAreaInsets() - const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75) + const {footerHeight} = useShellLayout() const keyboardHeight = useSharedValue(0) const keyboardIsOpening = useSharedValue(false) @@ -268,28 +269,30 @@ export function MessagesList({ onMove: e => { 'worklet' keyboardHeight.set(e.height) - if (e.height > bottomOffset) { + if (e.height > footerHeight.get()) { scrollTo(flatListRef, 0, 1e7, false) } }, onEnd: e => { 'worklet' keyboardHeight.set(e.height) - if (e.height > bottomOffset) { + if (e.height > footerHeight.get()) { scrollTo(flatListRef, 0, 1e7, false) } keyboardIsOpening.set(false) }, }, - [bottomOffset], + [footerHeight], ) const animatedListStyle = useAnimatedStyle(() => ({ - marginBottom: Math.max(keyboardHeight.get(), bottomOffset), + marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()), })) const animatedStickyViewStyle = useAnimatedStyle(() => ({ - transform: [{translateY: -Math.max(keyboardHeight.get(), bottomOffset)}], + transform: [ + {translateY: -Math.max(keyboardHeight.get(), footerHeight.get())}, + ], })) // -- Message sending @@ -437,18 +440,41 @@ export function MessagesList({ ) : blocked ? ( footer ) : ( - <> - {isConvoActive(convoState) && - !convoState.isFetchingHistory && - convoState.items.length === 0 && <ChatEmptyPill />} - <MessageInput - onSendMessage={onSendMessage} - hasEmbed={!!embedUri} - setEmbed={setEmbed} - openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> - <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> - </MessageInput> - </> + isConvoActive(convoState) && + !convoState.isFetchingHistory && ( + <> + {convoState.items.length === 0 ? ( + <> + <ChatEmptyPill /> + <MessageInput + onSendMessage={onSendMessage} + hasEmbed={!!embedUri} + setEmbed={setEmbed} + openEmojiPicker={pos => + setEmojiPickerState({isOpen: true, pos}) + }> + <MessageInputEmbed + embedUri={embedUri} + setEmbed={setEmbed} + /> + </MessageInput> + </> + ) : convoState.convo.status === 'request' && + !hasAcceptOverride ? ( + <ChatStatusInfo convoState={convoState} /> + ) : ( + <MessageInput + onSendMessage={onSendMessage} + hasEmbed={!!embedUri} + setEmbed={setEmbed} + openEmojiPicker={pos => + setEmojiPickerState({isOpen: true, pos}) + }> + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> + </MessageInput> + )} + </> + ) )} </Animated.View> diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx new file mode 100644 index 000000000..023cbff2d --- /dev/null +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -0,0 +1,254 @@ +import {useCallback} from 'react' +import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {StackActions, useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {NavigationProp} from '#/lib/routes/types' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useAcceptConversation} from '#/state/queries/messages/accept-conversation' +import {precacheConvoQuery} from '#/state/queries/messages/conversation' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ReportDialog} from '#/components/dms/ReportDialog' +import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' +import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' +import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' + +export function RejectMenu({ + convo, + profile, + size = 'tiny', + variant = 'outline', + color = 'secondary', + label, + showDeleteConvo, + currentScreen, + ...props +}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & { + label?: string + convo: ChatBskyConvoDefs.ConvoView + profile: ChatBskyActorDefs.ProfileViewBasic + showDeleteConvo?: boolean + currentScreen: 'list' | 'conversation' +}) { + const {_} = useLingui() + const shadowedProfile = useProfileShadow(profile) + const navigation = useNavigation<NavigationProp>() + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + onMutate: () => { + if (currentScreen === 'conversation') { + navigation.dispatch(StackActions.pop()) + } + }, + onError: () => { + Toast.show(_('Failed to delete chat'), 'xmark') + }, + }) + const [queueBlock] = useProfileBlockMutationQueue(shadowedProfile) + + const onPressDelete = useCallback(() => { + Toast.show(_('Chat deleted'), 'check') + leaveConvo() + }, [leaveConvo, _]) + + const onPressBlock = useCallback(() => { + Toast.show(_('Account blocked'), 'check') + // block and also delete convo + queueBlock() + leaveConvo() + }, [queueBlock, leaveConvo, _]) + + const reportControl = useDialogControl() + + const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage) + ? convo.lastMessage + : null + + return ( + <> + <Menu.Root> + <Menu.Trigger label={_(msg`Reject chat request`)}> + {({props: triggerProps}) => ( + <Button + {...triggerProps} + {...props} + label={triggerProps.accessibilityLabel} + style={[a.flex_1]} + color={color} + variant={variant} + size={size}> + <ButtonText> + {label || ( + <Trans comment="Reject a chat request, this opens a menu with options"> + Reject + </Trans> + )} + </ButtonText> + </Button> + )} + </Menu.Trigger> + <Menu.Outer> + <Menu.Group> + {showDeleteConvo && ( + <Menu.Item + label={_(msg`Delete conversation`)} + onPress={onPressDelete}> + <Menu.ItemText> + <Trans>Delete conversation</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CircleX_Stroke2_Corner0_Rounded} /> + </Menu.Item> + )} + <Menu.Item label={_(msg`Block account`)} onPress={onPressBlock}> + <Menu.ItemText> + <Trans>Block account</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={PersonXIcon} /> + </Menu.Item> + {/* note: last message will almost certainly be defined, since you can't + delete messages for other people andit's impossible for a convo on this + screen to have a message sent by you */} + {lastMessage && ( + <Menu.Item + label={_(msg`Report conversation`)} + onPress={reportControl.open}> + <Menu.ItemText> + <Trans>Report conversation</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={FlagIcon} /> + </Menu.Item> + )} + </Menu.Group> + </Menu.Outer> + </Menu.Root> + {lastMessage && ( + <ReportDialog + currentScreen={currentScreen} + params={{ + type: 'convoMessage', + convoId: convo.id, + message: lastMessage, + }} + control={reportControl} + /> + )} + </> + ) +} + +export function AcceptChatButton({ + convo, + size = 'tiny', + variant = 'solid', + color = 'secondary_inverted', + label, + currentScreen, + onAcceptConvo, + ...props +}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & { + label?: string + convo: ChatBskyConvoDefs.ConvoView + onAcceptConvo?: () => void + currentScreen: 'list' | 'conversation' +}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const navigation = useNavigation<NavigationProp>() + + const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, { + onMutate: () => { + onAcceptConvo?.() + if (currentScreen === 'list') { + precacheConvoQuery(queryClient, {...convo, status: 'accepted'}) + navigation.navigate('MessagesConversation', { + conversation: convo.id, + accept: true, + }) + } + }, + onError: () => { + // Should we show a toast here? They'll be on the convo screen, and it'll make + // no difference if the request failed - when they send a message, the convo will be accepted + // 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 + Toast.show(_('Failed to accept chat'), 'xmark') + }, + }) + + const onPressAccept = useCallback(() => { + acceptConvo() + }, [acceptConvo]) + + return ( + <Button + {...props} + label={label || _(msg`Accept chat request`)} + size={size} + variant={variant} + color={color} + style={a.flex_1} + onPress={onPressAccept}> + {isPending ? ( + <ButtonIcon icon={Loader} /> + ) : ( + <ButtonText> + {label || <Trans comment="Accept a chat request">Accept</Trans>} + </ButtonText> + )} + </Button> + ) +} + +export function DeleteChatButton({ + convo, + size = 'tiny', + variant = 'outline', + color = 'secondary', + label, + currentScreen, + ...props +}: Omit<ButtonProps, 'children' | 'label'> & { + label?: string + convo: ChatBskyConvoDefs.ConvoView + currentScreen: 'list' | 'conversation' +}) { + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + onMutate: () => { + if (currentScreen === 'conversation') { + navigation.dispatch(StackActions.pop()) + } + }, + onError: () => { + Toast.show(_('Failed to delete chat'), 'xmark') + }, + }) + + const onPressDelete = useCallback(() => { + Toast.show(_('Chat deleted'), 'check') + leaveConvo() + }, [leaveConvo, _]) + + return ( + <Button + label={label || _(msg`Delete chat`)} + size={size} + variant={variant} + color={color} + style={a.flex_1} + onPress={onPressDelete} + {...props}> + <ButtonText>{label || <Trans>Delete chat</Trans>}</ButtonText> + </Button> + ) +} diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/RequestListItem.tsx new file mode 100644 index 000000000..654691a01 --- /dev/null +++ b/src/screens/Messages/components/RequestListItem.tsx @@ -0,0 +1,78 @@ +import {View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {atoms as a, tokens} from '#/alf' +import {KnownFollowers} from '#/components/KnownFollowers' +import {Text} from '#/components/Typography' +import {ChatListItem} from './ChatListItem' +import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' + +export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + const otherUser = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherUser || !moderationOpts) { + return null + } + + const isDeletedAccount = otherUser.handle === 'missing.invalid' + + return ( + <View style={[a.relative, a.flex_1]}> + <ChatListItem convo={convo} showMenu={false}> + <View style={[a.pt_xs, a.pb_2xs]}> + <KnownFollowers + profile={otherUser} + moderationOpts={moderationOpts} + minimal + showIfEmpty + /> + </View> + {/* spacer, since you can't nest pressables */} + <View style={[a.pt_md, a.pb_xs, a.w_full, {opacity: 0}]} aria-hidden> + {/* Placeholder text so that it responds to the font height */} + <Text style={[a.text_xs, a.leading_tight, a.font_bold]}> + <Trans comment="Accept a chat request">Accept Request</Trans> + </Text> + </View> + </ChatListItem> + <View + style={[ + a.absolute, + a.pr_md, + a.w_full, + a.flex_row, + a.align_center, + a.gap_sm, + { + bottom: tokens.space.md, + paddingLeft: tokens.space.lg + 52 + tokens.space.md, + }, + ]}> + {!isDeletedAccount ? ( + <> + <AcceptChatButton convo={convo} currentScreen="list" /> + <RejectMenu + convo={convo} + profile={otherUser} + showDeleteConvo + currentScreen="list" + /> + </> + ) : ( + <> + <DeleteChatButton convo={convo} currentScreen="list" /> + <View style={a.flex_1} /> + </> + )} + </View> + </View> + ) +} |