diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/Messages/ChatList.tsx | 102 | ||||
-rw-r--r-- | src/screens/Messages/Conversation.tsx | 10 | ||||
-rw-r--r-- | src/screens/Messages/Inbox.tsx | 332 | ||||
-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 | ||||
-rw-r--r-- | src/screens/Settings/Settings.tsx | 4 |
10 files changed, 992 insertions, 75 deletions
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 32b111def..b060b23e5 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react' import {View} from 'react-native' import {useAnimatedRef} from 'react-native-reanimated' -import {ChatBskyConvoDefs} from '@atproto/api' +import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect, useIsFocused} from '@react-navigation/native' @@ -18,6 +18,7 @@ import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {useMessagesEventBus} from '#/state/messages/events' import {useLeftConvos} from '#/state/queries/messages/leave-conversation' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useSession} from '#/state/session' import {List, ListRef} from '#/view/com/util/List' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -35,20 +36,37 @@ import {ListFooter} from '#/components/Lists' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {ChatListItem} from './components/ChatListItem' +import {InboxPreview} from './components/InboxPreview' -type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> +type ListItem = + | { + type: 'INBOX' + count: number + profiles: ChatBskyActorDefs.ProfileViewBasic[] + } + | { + type: 'CONVERSATION' + conversation: ChatBskyConvoDefs.ConvoView + } -function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { - return <ChatListItem convo={item} /> +function renderItem({item}: {item: ListItem}) { + switch (item.type) { + case 'INBOX': + return <InboxPreview count={item.count} profiles={item.profiles} /> + case 'CONVERSATION': + return <ChatListItem convo={item.conversation} /> + } } -function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { - return item.id +function keyExtractor(item: ListItem) { + return item.type === 'INBOX' ? 'INBOX' : item.conversation.id } +type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> export function MessagesScreen({navigation, route}: Props) { const {_} = useLingui() const t = useTheme() + const {currentAccount} = useSession() const newChatControl = useDialogControl() const scrollElRef: ListRef = useAnimatedRef() const pushToConversation = route.params?.pushToConversation @@ -94,33 +112,63 @@ export function MessagesScreen({navigation, route}: Props) { isError, error, refetch, - } = useListConvosQuery() + } = useListConvosQuery({status: 'accepted'}) + + const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({ + status: 'request', + }) useRefreshOnFocus(refetch) + useRefreshOnFocus(refetchInbox) const leftConvos = useLeftConvos() + const inboxPreviewConvos = useMemo(() => { + const inbox = + inboxData?.pages + .flatMap(page => page.convos) + .filter( + convo => + !leftConvos.includes(convo.id) && + !convo.muted && + convo.unreadCount > 0, + ) ?? [] + + return inbox + .map(x => x.members.find(y => y.did !== currentAccount?.did)) + .filter(x => !!x) + }, [inboxData, leftConvos, currentAccount?.did]) + const conversations = useMemo(() => { if (data?.pages) { - return ( - data.pages - .flatMap(page => page.convos) - // filter out convos that are actively being left - .filter(convo => !leftConvos.includes(convo.id)) - ) + const conversations = data.pages + .flatMap(page => page.convos) + // filter out convos that are actively being left + .filter(convo => !leftConvos.includes(convo.id)) + + return [ + { + type: 'INBOX', + count: inboxPreviewConvos.length, + profiles: inboxPreviewConvos.slice(0, 3), + }, + ...conversations.map( + convo => ({type: 'CONVERSATION', conversation: convo} as const), + ), + ] satisfies ListItem[] } return [] - }, [data, leftConvos]) + }, [data, leftConvos, inboxPreviewConvos]) const onRefresh = useCallback(async () => { setIsPTRing(true) try { - await refetch() + await Promise.all([refetch(), refetchInbox()]) } catch (err) { logger.error('Failed to refresh conversations', {message: err}) } setIsPTRing(false) - }, [refetch, setIsPTRing]) + }, [refetch, refetchInbox, setIsPTRing]) const onEndReached = useCallback(async () => { if (isFetchingNextPage || !hasNextPage || isError) return @@ -157,7 +205,8 @@ export function MessagesScreen({navigation, route}: Props) { return listenSoftReset(onSoftReset) }, [onSoftReset, isScreenFocused]) - if (conversations.length < 1) { + // Will always have 1 item - the inbox button + if (conversations.length < 2) { return ( <Layout.Screen> <Header newChatControl={newChatControl} /> @@ -173,7 +222,7 @@ export function MessagesScreen({navigation, route}: Props) { <View style={[a.pt_3xl, a.align_center]}> <CircleInfo width={48} - fill={t.atoms.border_contrast_low.borderColor} + fill={t.atoms.text_contrast_low.color} /> <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> <Trans>Whoops!</Trans> @@ -187,13 +236,14 @@ export function MessagesScreen({navigation, route}: Props) { t.atoms.text_contrast_medium, {maxWidth: 360}, ]}> - {cleanError(error)} + {cleanError(error) || + _(msg`Failed to load conversations`)} </Text> <Button label={_(msg`Reload conversations`)} - size="large" - color="secondary" + size="small" + color="secondary_inverted" variant="solid" onPress={() => refetch()}> <ButtonText> @@ -205,6 +255,10 @@ export function MessagesScreen({navigation, route}: Props) { </> ) : ( <> + <InboxPreview + count={inboxPreviewConvos.length} + profiles={inboxPreviewConvos} + /> <View style={[a.pt_3xl, a.align_center]}> <Message width={48} fill={t.palette.primary_500} /> <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> @@ -253,8 +307,6 @@ export function MessagesScreen({navigation, route}: Props) { onRetry={fetchNextPage} style={{borderColor: 'transparent'}} hasNextPage={hasNextPage} - showEndMessage={true} - endMessageText={_(msg`No more conversations to show`)} /> } onEndReachedThreshold={isNative ? 1.5 : 0} @@ -290,7 +342,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) { <> <Layout.Header.Content> <Layout.Header.TitleText> - <Trans>Messages</Trans> + <Trans>Chats</Trans> </Layout.Header.TitleText> </Layout.Header.Content> @@ -314,7 +366,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) { <Layout.Header.MenuButton /> <Layout.Header.Content> <Layout.Header.TitleText> - <Trans>Messages</Trans> + <Trans>Chats</Trans> </Layout.Header.TitleText> </Layout.Header.Content> <Layout.Header.Slot>{settingsLink}</Layout.Header.Slot> diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 69af0ea58..cac5ff157 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -7,7 +7,12 @@ import { } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useFocusEffect, useNavigation} from '@react-navigation/native' +import { + RouteProp, + useFocusEffect, + useNavigation, + useRoute, +} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useEmail} from '#/lib/hooks/useEmail' @@ -172,6 +177,8 @@ function InnerReady({ const {_} = useLingui() const convoState = useConvo() const navigation = useNavigation<NavigationProp>() + const {params} = + useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() const verifyEmailControl = useDialogControl() const {needsEmailVerification} = useEmail() @@ -189,6 +196,7 @@ function InnerReady({ hasScrolled={hasScrolled} setHasScrolled={setHasScrolled} blocked={moderation?.blocked} + hasAcceptOverride={!!params.accept} footer={ <MessagesListBlockedFooter recipient={recipient} diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx new file mode 100644 index 000000000..3f3d5a8a8 --- /dev/null +++ b/src/screens/Messages/Inbox.tsx @@ -0,0 +1,332 @@ +import {useCallback, useMemo, useState} from 'react' +import {View} from 'react-native' +import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' + +import {useAppState} from '#/lib/hooks/useAppState' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import { + CommonNavigatorParams, + NativeStackScreenProps, + NavigationProp, +} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' +import {useMessagesEventBus} from '#/state/messages/events' +import {useLeftConvos} from '#/state/queries/messages/leave-conversation' +import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' +import {FAB} from '#/view/com/util/fab/FAB' +import {List} from '#/view/com/util/List' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' +import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' +import * as Layout from '#/components/Layout' +import {ListFooter} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {RequestListItem} from './components/RequestListItem' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'> +export function MessagesInboxScreen({}: Props) { + const {gtTablet} = useBreakpoints() + + const listConvosQuery = useListConvosQuery({status: 'request'}) + const {data} = listConvosQuery + + const leftConvos = useLeftConvos() + + const conversations = useMemo(() => { + if (data?.pages) { + const convos = data.pages + .flatMap(page => page.convos) + // filter out convos that are actively being left + .filter(convo => !leftConvos.includes(convo.id)) + + return convos + } + return [] + }, [data, leftConvos]) + + const hasUnreadConvos = useMemo(() => { + return conversations.some(conversation => conversation.unreadCount > 0) + }, [conversations]) + + return ( + <Layout.Screen testID="messagesInboxScreen"> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> + <Layout.Header.TitleText> + <Trans>Chat requests</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + {hasUnreadConvos && gtTablet ? ( + <MarkAsReadHeaderButton /> + ) : ( + <Layout.Header.Slot /> + )} + </Layout.Header.Outer> + <RequestList + listConvosQuery={listConvosQuery} + conversations={conversations} + hasUnreadConvos={hasUnreadConvos} + /> + </Layout.Screen> + ) +} + +function RequestList({ + listConvosQuery, + conversations, + hasUnreadConvos, +}: { + listConvosQuery: UseInfiniteQueryResult< + InfiniteData<ChatBskyConvoListConvos.OutputSchema>, + Error + > + conversations: ChatBskyConvoDefs.ConvoView[] + hasUnreadConvos: boolean +}) { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + + // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) + // but only when the screen is active + const messagesBus = useMessagesEventBus() + const state = useAppState() + const isActive = state === 'active' + useFocusEffect( + useCallback(() => { + if (isActive) { + const unsub = messagesBus.requestPollInterval( + MESSAGE_SCREEN_POLL_INTERVAL, + ) + return () => unsub() + } + }, [messagesBus, isActive]), + ) + + const initialNumToRender = useInitialNumToRender({minItemHeight: 130}) + const [isPTRing, setIsPTRing] = useState(false) + + const { + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = listConvosQuery + + useRefreshOnFocus(refetch) + + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh conversations', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more conversations', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + if (conversations.length < 1) { + return ( + <Layout.Center> + {isLoading ? ( + <View style={[a.align_center, a.pt_3xl, web({paddingTop: '10vh'})]}> + <Loader size="xl" /> + </View> + ) : ( + <> + {isError ? ( + <> + <View style={[a.pt_3xl, a.align_center]}> + <CircleInfoIcon + width={48} + fill={t.atoms.text_contrast_low.color} + /> + <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> + <Trans>Whoops!</Trans> + </Text> + <Text + style={[ + a.text_md, + a.pb_xl, + a.text_center, + a.leading_snug, + t.atoms.text_contrast_medium, + {maxWidth: 360}, + ]}> + {cleanError(error) || _(msg`Failed to load conversations`)} + </Text> + + <Button + label={_(msg`Reload conversations`)} + size="small" + color="secondary_inverted" + variant="solid" + onPress={() => refetch()}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + <ButtonIcon icon={RetryIcon} position="right" /> + </Button> + </View> + </> + ) : ( + <> + <View style={[a.pt_3xl, a.align_center]}> + <MessageIcon width={48} fill={t.palette.primary_500} /> + <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> + <Trans comment="Title message shown in chat requests inbox when it's empty"> + Inbox zero! + </Trans> + </Text> + <Text + style={[ + a.text_md, + a.pb_xl, + a.text_center, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + <Trans> + You don't have any chat requests at the moment. + </Trans> + </Text> + <Button + variant="solid" + color="secondary" + size="small" + label={_(msg`Go back`)} + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Messages', {animation: 'pop'}) + } + }}> + <ButtonIcon icon={ArrowLeftIcon} /> + <ButtonText> + <Trans>Back to Chats</Trans> + </ButtonText> + </Button> + </View> + </> + )} + </> + )} + </Layout.Center> + ) + } + + return ( + <> + <List + data={conversations} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + ListFooterComponent={ + <ListFooter + isFetchingNextPage={isFetchingNextPage} + error={cleanError(error)} + onRetry={fetchNextPage} + style={{borderColor: 'transparent'}} + hasNextPage={hasNextPage} + /> + } + onEndReachedThreshold={isNative ? 1.5 : 0} + initialNumToRender={initialNumToRender} + windowSize={11} + desktopFixedHeight + sideBorders={false} + /> + {hasUnreadConvos && <MarkAllReadFAB />} + </> + ) +} + +function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { + return item.id +} + +function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { + return <RequestListItem convo={item} /> +} + +function MarkAllReadFAB() { + const {_} = useLingui() + const t = useTheme() + const {mutate: markAllRead} = useUpdateAllRead('request', { + onMutate: () => { + Toast.show(_(msg`Marked all as read`), 'check') + }, + onError: () => { + Toast.show(_(msg`Failed to mark all requests as read`), 'xmark') + }, + }) + + return ( + <FAB + testID="markAllAsReadFAB" + onPress={() => markAllRead()} + icon={<CheckIcon size="lg" fill={t.palette.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`Mark all as read`)} + accessibilityHint="" + /> + ) +} + +function MarkAsReadHeaderButton() { + const {_} = useLingui() + const {mutate: markAllRead} = useUpdateAllRead('request', { + onMutate: () => { + Toast.show(_(msg`Marked all as read`), 'check') + }, + onError: () => { + Toast.show(_(msg`Failed to mark all requests as read`), 'xmark') + }, + }) + + return ( + <Button + label={_(msg`Mark all as read`)} + size="small" + color="secondary" + variant="solid" + onPress={() => markAllRead()}> + <ButtonIcon icon={CheckIcon} /> + <ButtonText> + <Trans>Mark all as read</Trans> + </ButtonText> + </Button> + ) +} 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> + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index ea83b00c2..b8cdfdcb4 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -28,7 +28,7 @@ import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName' import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, tokens, useTheme} from '#/alf' -import {AvatarStack} from '#/components/AvatarStack' +import {AvatarStackWithFetch} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' @@ -118,7 +118,7 @@ export function SettingsScreen({}: Props) { {showAccounts ? ( <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" /> ) : ( - <AvatarStack + <AvatarStackWithFetch profiles={accounts .map(acc => acc.did) .filter(did => did !== currentAccount?.did) |