From c995eb2f2fa3e73dcc6943078c85cd6a68f5370b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Mar 2025 13:54:19 +0000 Subject: DMs inbox (#7778) * improve error screen * add chat request prompt * mock up inbox * bigger button * use two-button layout * get inbox working somewhat * fix type errors * fetch both pages for badge * don't include read convos in preview * in-chat ui for non-accepted convos (part 1) * add chatstatusinfo * fix status info not disappearing * get chat status info working * change min item height * move files around * add updated sdk * improve badge behaviour * mock up mark all as read * update sdk to 0.14.4 * hide chat status info if initiating convo * fix unread count for deleted accounts * add toasts after rejection * add prompt to delete * adjust badge on desktop * requests -> chat requests * fix height flicker * add mark as read button to header * add mark all as read APIs * separate avatarstack into two components (#7845) * fix messages being hidden behind chatstatusinfo * show inbox preview on empty state * fix empty state again * Use new convo availability API (#7812) * [Inbox] Accept button on convo screen (#7795) * accept button on convo screen * fix types * fix type error * improve spacing * [DMs] Implement new log types (#7835) * optimise badge state * add read message log * add isLogAcceptConvo * mute/unmute convo logs * use setqueriesdata * always show label on button * optimistically update badge * change incorrect unread count change * Update src/screens/Messages/Inbox.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestButtons.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestButtons.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestListItem.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix race condition with accepting convo * fix back button on web * filter left convos from badge * update atproto to fix CI * Add accept override external to convo (#7891) * Add accept override external to convo * rm log --------- Co-authored-by: Samuel Newman --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey --- assets/icons/circleX_stroke2_corner0_rounded.svg | 1 + src/Navigation.tsx | 6 + src/alf/atoms.ts | 15 + src/components/AvatarStack.tsx | 52 ++- src/components/KnownFollowers.tsx | 29 +- src/components/dms/LeaveConvoPrompt.tsx | 6 +- src/components/dms/MessageProfileButton.tsx | 45 ++- src/components/dms/MessagesListHeader.tsx | 6 +- src/components/dms/ReportDialog.tsx | 25 +- src/components/icons/CircleX.tsx | 5 + src/lib/routes/types.ts | 3 +- src/routes.ts | 1 + src/screens/Messages/ChatList.tsx | 102 +++-- src/screens/Messages/Conversation.tsx | 10 +- src/screens/Messages/Inbox.tsx | 332 ++++++++++++++++ src/screens/Messages/components/ChatListItem.tsx | 65 ++-- src/screens/Messages/components/ChatStatusInfo.tsx | 81 ++++ src/screens/Messages/components/InboxPreview.tsx | 73 ++++ src/screens/Messages/components/MessagesList.tsx | 68 +++- src/screens/Messages/components/RequestButtons.tsx | 254 +++++++++++++ .../Messages/components/RequestListItem.tsx | 78 ++++ src/screens/Settings/Settings.tsx | 4 +- src/state/messages/convo/agent.ts | 21 + src/state/messages/convo/index.tsx | 4 +- src/state/messages/convo/types.ts | 8 + src/state/messages/convo/util.ts | 18 +- src/state/queries/messages/accept-conversation.ts | 135 +++++++ src/state/queries/messages/conversation.ts | 53 +-- .../queries/messages/get-convo-availability.ts | 25 ++ .../queries/messages/get-convo-for-members.ts | 35 +- src/state/queries/messages/leave-conversation.ts | 18 +- src/state/queries/messages/list-conversations.tsx | 421 +++++++++++++++------ src/state/queries/messages/mute-conversation.ts | 4 +- src/state/queries/messages/update-all-read.ts | 105 +++++ src/state/queries/profile.ts | 4 +- src/view/com/util/UserAvatar.tsx | 8 +- src/view/shell/bottom-bar/BottomBar.tsx | 1 + src/view/shell/bottom-bar/BottomBarStyles.tsx | 3 +- src/view/shell/bottom-bar/BottomBarWeb.tsx | 7 +- src/view/shell/desktop/LeftNav.tsx | 9 +- 40 files changed, 1800 insertions(+), 340 deletions(-) create mode 100644 assets/icons/circleX_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/CircleX.tsx create mode 100644 src/screens/Messages/Inbox.tsx create mode 100644 src/screens/Messages/components/ChatStatusInfo.tsx create mode 100644 src/screens/Messages/components/InboxPreview.tsx create mode 100644 src/screens/Messages/components/RequestButtons.tsx create mode 100644 src/screens/Messages/components/RequestListItem.tsx create mode 100644 src/state/queries/messages/accept-conversation.ts create mode 100644 src/state/queries/messages/get-convo-availability.ts create mode 100644 src/state/queries/messages/update-all-read.ts diff --git a/assets/icons/circleX_stroke2_corner0_rounded.svg b/assets/icons/circleX_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..c887d0e90 --- /dev/null +++ b/assets/icons/circleX_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index baf99f110..807fd92e5 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -69,6 +69,7 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest import HashtagScreen from '#/screens/Hashtag' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' +import {MessagesInboxScreen} from '#/screens/Messages/Inbox' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' @@ -411,6 +412,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => MessagesSettingsScreen} options={{title: title(msg`Chat settings`), requireAuth: true}} /> + MessagesInboxScreen} + options={{title: title(msg`Chat request inbox`), requireAuth: true}} + /> NotificationSettingsScreen} diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index c9db8accc..1d3d5cab3 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -31,6 +31,18 @@ export const atoms = { right: 0, bottom: 0, }, + top_0: { + top: 0, + }, + right_0: { + right: 0, + }, + bottom_0: { + bottom: 0, + }, + left_0: { + left: 0, + }, z_10: { zIndex: 10, }, @@ -93,6 +105,9 @@ export const atoms = { /* * Border radius */ + rounded_0: { + borderRadius: 0, + }, rounded_2xs: { borderRadius: tokens.borderRadius._2xs, }, diff --git a/src/components/AvatarStack.tsx b/src/components/AvatarStack.tsx index 1b27a95ac..63f5ed77a 100644 --- a/src/components/AvatarStack.tsx +++ b/src/components/AvatarStack.tsx @@ -1,37 +1,37 @@ import {View} from 'react-native' import {moderateProfile} from '@atproto/api' +import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfilesQuery} from '#/state/queries/profile' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' +import * as bsky from '#/types/bsky' export function AvatarStack({ profiles, size = 26, + numPending, + backgroundColor, }: { - profiles: string[] + profiles: bsky.profile.AnyProfileView[] size?: number + numPending?: number + backgroundColor?: string }) { const halfSize = size / 2 - const {data, error} = useProfilesQuery({handles: profiles}) const t = useTheme() const moderationOpts = useModerationOpts() - if (error) { - console.error(error) - return null - } - - const isPending = !data || !moderationOpts + const isPending = (numPending && profiles.length === 0) || !moderationOpts const items = isPending - ? Array.from({length: profiles.length}).map((_, i) => ({ + ? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({ key: i, profile: null, moderation: null, })) - : data.profiles.map(item => ({ + : profiles.map(item => ({ key: item.did, profile: item, moderation: moderateProfile(item, moderationOpts), @@ -56,7 +56,7 @@ export function AvatarStack({ height: size, left: i * -halfSize, borderWidth: 1, - borderColor: t.atoms.bg.backgroundColor, + borderColor: backgroundColor ?? t.atoms.bg.backgroundColor, borderRadius: 999, zIndex: 3 - i, }, @@ -74,3 +74,33 @@ export function AvatarStack({ ) } + +export function AvatarStackWithFetch({ + profiles, + size, + backgroundColor, +}: { + profiles: string[] + size?: number + backgroundColor?: string +}) { + const {data, error} = useProfilesQuery({handles: profiles}) + + if (error) { + if (error.name !== 'AbortError') { + logger.error('Error fetching profiles for AvatarStack', { + safeMessage: error, + }) + } + return null + } + + return ( + + ) +} diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index 1e7cf448a..a883066ca 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -33,11 +33,13 @@ export function KnownFollowers({ moderationOpts, onLinkPress, minimal, + showIfEmpty, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onLinkPress?: LinkProps['onPress'] minimal?: boolean + showIfEmpty?: boolean }) { const cache = React.useRef>( new Map(), @@ -64,11 +66,12 @@ export function KnownFollowers({ moderationOpts={moderationOpts} onLinkPress={onLinkPress} minimal={minimal} + showIfEmpty={showIfEmpty} /> ) } - return null + return } function KnownFollowersInner({ @@ -77,22 +80,19 @@ function KnownFollowersInner({ cachedKnownFollowers, onLinkPress, minimal, + showIfEmpty, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts cachedKnownFollowers: AppBskyActorDefs.KnownFollowers onLinkPress?: LinkProps['onPress'] minimal?: boolean + showIfEmpty?: boolean }) { const t = useTheme() const {_} = useLingui() - const textStyle = [ - a.flex_1, - a.text_sm, - a.leading_snug, - t.atoms.text_contrast_medium, - ] + const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium] const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const moderation = moderateProfile(f, moderationOpts) @@ -115,7 +115,7 @@ function KnownFollowersInner({ * We check above too, but here for clarity and a reminder to _check for * valid indices_ */ - if (slice.length === 0) return null + if (slice.length === 0) return const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE @@ -127,7 +127,6 @@ function KnownFollowersInner({ onPress={onLinkPress} to={makeProfileLink(profile, 'known-followers')} style={[ - a.flex_1, a.flex_row, minimal ? a.gap_sm : a.gap_md, a.align_center, @@ -243,3 +242,15 @@ function KnownFollowersInner({ ) } + +function EmptyFallback({show}: {show?: boolean}) { + const t = useTheme() + + if (!show) return null + + return ( + + Not followed by anyone you're following + + ) +} diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index c99f8d063..57acd5ca7 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -13,10 +13,12 @@ export function LeaveConvoPrompt({ control, convoId, currentScreen, + hasMessages = true, }: { control: DialogOuterProps['control'] convoId: string currentScreen: 'list' | 'conversation' + hasMessages?: boolean }) { const {_} = useLingui() const navigation = useNavigation() @@ -39,7 +41,9 @@ export function LeaveConvoPrompt({ control={control} title={_(msg`Leave conversation`)} description={_( - msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, + hasMessages + ? msg`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?`, )} confirmButtonCta={_(msg`Leave`)} confirmButtonColor="negative" diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 5eac7f5c5..7f31f550c 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -8,13 +8,15 @@ import {useNavigation} from '@react-navigation/native' import {useEmail} from '#/lib/hooks/useEmail' import {NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' -import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' +import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' +import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {canBeMessaged} from '#/components/dms/util' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' -import {useDialogControl} from '../Dialog' -import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog' export function MessageProfileButton({ profile, @@ -27,10 +29,19 @@ export function MessageProfileButton({ const {needsEmailVerification} = useEmail() const verifyEmailControl = useDialogControl() - const {data: convo, isPending} = useMaybeConvoForUser(profile.did) + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) + const {mutate: initiateConvo} = useGetConvoForMembers({ + onSuccess: ({convo}) => { + logEvent('chat:open', {logContext: 'ProfileHeader'}) + navigation.navigate('MessagesConversation', {conversation: convo.id}) + }, + onError: () => { + Toast.show(_(msg`Failed to create conversation`)) + }, + }) const onPress = React.useCallback(() => { - if (!convo?.id) { + if (!convoAvailability?.canChat) { return } @@ -39,15 +50,25 @@ export function MessageProfileButton({ return } - if (convo && !convo.lastMessage) { + if (convoAvailability.convo) { + logEvent('chat:open', {logContext: 'ProfileHeader'}) + navigation.navigate('MessagesConversation', { + conversation: convoAvailability.convo.id, + }) + } else { logEvent('chat:create', {logContext: 'ProfileHeader'}) + initiateConvo([profile.did]) } - logEvent('chat:open', {logContext: 'ProfileHeader'}) - - navigation.navigate('MessagesConversation', {conversation: convo.id}) - }, [needsEmailVerification, verifyEmailControl, convo, navigation]) + }, [ + needsEmailVerification, + verifyEmailControl, + navigation, + profile.did, + initiateConvo, + convoAvailability, + ]) - if (isPending) { + if (!convoAvailability) { // show pending state based on declaration if (canBeMessaged(profile)) { return ( @@ -69,7 +90,7 @@ export function MessageProfileButton({ } } - if (convo) { + if (convoAvailability.canChat) { return ( <> + + + ) : ( + <> + + + + + Inbox zero! + + + + + You don't have any chat requests at the moment. + + + + + + )} + + )} + + ) + } + + return ( + <> + + } + onEndReachedThreshold={isNative ? 1.5 : 0} + initialNumToRender={initialNumToRender} + windowSize={11} + desktopFixedHeight + sideBorders={false} + /> + {hasUnreadConvos && } + + ) +} + +function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { + return item.id +} + +function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { + return +} + +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 ( + markAllRead()} + icon={} + 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 ( + + ) +} 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} + ) } @@ -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 ( {/* Avatar goes here */} @@ -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} - {convo.unreadCount > 0 && ( + {hasUnread && ( - 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 && ( + 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} + /> + )} { + convoState.markConvoAccepted() + }, [convoState]) + + const otherUser = convoState.recipients.find( + user => user.did !== currentAccount?.did, + ) + + if (!moderationOpts) { + return null + } + + return ( + + {otherUser && ( + + )} + + {otherUser && ( + + )} + + + + + + + + ) +} 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 ( + + + + {profiles.length > 0 && ( + + )} + + + Chat requests + + + + + ) +} 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> 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 && } - setEmojiPickerState({isOpen: true, pos})}> - - - + isConvoActive(convoState) && + !convoState.isFetchingHistory && ( + <> + {convoState.items.length === 0 ? ( + <> + + + setEmojiPickerState({isOpen: true, pos}) + }> + + + + ) : convoState.convo.status === 'request' && + !hasAcceptOverride ? ( + + ) : ( + + setEmojiPickerState({isOpen: true, pos}) + }> + + + )} + + ) )} 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 & { + label?: string + convo: ChatBskyConvoDefs.ConvoView + profile: ChatBskyActorDefs.ProfileViewBasic + showDeleteConvo?: boolean + currentScreen: 'list' | 'conversation' +}) { + const {_} = useLingui() + const shadowedProfile = useProfileShadow(profile) + const navigation = useNavigation() + 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 ( + <> + + + {({props: triggerProps}) => ( + + )} + + + + {showDeleteConvo && ( + + + Delete conversation + + + + )} + + + Block account + + + + {/* 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 && ( + + + Report conversation + + + + )} + + + + {lastMessage && ( + + )} + + ) +} + +export function AcceptChatButton({ + convo, + size = 'tiny', + variant = 'solid', + color = 'secondary_inverted', + label, + currentScreen, + onAcceptConvo, + ...props +}: Omit & { + label?: string + convo: ChatBskyConvoDefs.ConvoView + onAcceptConvo?: () => void + currentScreen: 'list' | 'conversation' +}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const navigation = useNavigation() + + 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 ( + + ) +} + +export function DeleteChatButton({ + convo, + size = 'tiny', + variant = 'outline', + color = 'secondary', + label, + currentScreen, + ...props +}: Omit & { + label?: string + convo: ChatBskyConvoDefs.ConvoView + currentScreen: 'list' | 'conversation' +}) { + const {_} = useLingui() + const navigation = useNavigation() + + 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 ( + + ) +} 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 ( + + + + + + {/* spacer, since you can't nest pressables */} + + {/* Placeholder text so that it responds to the font height */} + + Accept Request + + + + + {!isDeletedAccount ? ( + <> + + + + ) : ( + <> + + + + )} + + + ) +} 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 ? ( ) : ( - acc.did) .filter(did => did !== currentAccount?.did) diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 73e75f58d..f6a8d6dc4 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -105,6 +105,7 @@ export class Convo { this.ingestFirehose = this.ingestFirehose.bind(this) this.onFirehoseConnect = this.onFirehoseConnect.bind(this) this.onFirehoseError = this.onFirehoseError.bind(this) + this.markConvoAccepted = this.markConvoAccepted.bind(this) } private commit() { @@ -145,6 +146,7 @@ export class Convo { deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, + markConvoAccepted: undefined, } } case ConvoStatus.Disabled: @@ -162,6 +164,7 @@ export class Convo { deleteMessage: this.deleteMessage, sendMessage: this.sendMessage, fetchMessageHistory: this.fetchMessageHistory, + markConvoAccepted: this.markConvoAccepted, } } case ConvoStatus.Error: { @@ -176,6 +179,7 @@ export class Convo { deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, + markConvoAccepted: undefined, } } default: { @@ -190,6 +194,7 @@ export class Convo { deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, + markConvoAccepted: undefined, } } } @@ -780,6 +785,12 @@ export class Convo { id: tempId, message, }) + if (this.convo?.status === 'request') { + this.convo = { + ...this.convo, + status: 'accepted', + } + } this.commit() if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { @@ -787,6 +798,16 @@ export class Convo { } } + markConvoAccepted() { + if (this.convo) { + this.convo = { + ...this.convo, + status: 'accepted', + } + } + this.commit() + } + async processPendingMessages() { logger.debug( `Convo: processing messages (${this.pendingMessages.size} remaining)`, diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index a1750bdf0..f004566e8 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -19,7 +19,7 @@ import { RQKEY as getConvoKey, useMarkAsReadMutation, } from '#/state/queries/messages/conversation' -import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-conversations' +import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' import {useAgent} from '#/state/session' @@ -104,7 +104,7 @@ export function ConvoProvider({ }) } queryClient.invalidateQueries({ - queryKey: ListConvosQueryKey, + queryKey: [ListConvosQueryKeyRoot], }) } } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 69e15acc4..83499de2e 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -141,6 +141,7 @@ type SendMessage = ( message: ChatBskyConvoSendMessage.InputSchema['message'], ) => void type FetchMessageHistory = () => Promise +type MarkConvoAccepted = () => void export type ConvoStateUninitialized = { status: ConvoStatus.Uninitialized @@ -153,6 +154,7 @@ export type ConvoStateUninitialized = { deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined + markConvoAccepted: undefined } export type ConvoStateInitializing = { status: ConvoStatus.Initializing @@ -165,6 +167,7 @@ export type ConvoStateInitializing = { deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined + markConvoAccepted: undefined } export type ConvoStateReady = { status: ConvoStatus.Ready @@ -177,6 +180,7 @@ export type ConvoStateReady = { deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory + markConvoAccepted: MarkConvoAccepted } export type ConvoStateBackgrounded = { status: ConvoStatus.Backgrounded @@ -189,6 +193,7 @@ export type ConvoStateBackgrounded = { deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory + markConvoAccepted: MarkConvoAccepted } export type ConvoStateSuspended = { status: ConvoStatus.Suspended @@ -201,6 +206,7 @@ export type ConvoStateSuspended = { deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory + markConvoAccepted: MarkConvoAccepted } export type ConvoStateError = { status: ConvoStatus.Error @@ -213,6 +219,7 @@ export type ConvoStateError = { deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined + markConvoAccepted: undefined } export type ConvoStateDisabled = { status: ConvoStatus.Disabled @@ -225,6 +232,7 @@ export type ConvoStateDisabled = { deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory + markConvoAccepted: MarkConvoAccepted } export type ConvoState = | ConvoStateUninitialized diff --git a/src/state/messages/convo/util.ts b/src/state/messages/convo/util.ts index 200d85dfa..92046cf1f 100644 --- a/src/state/messages/convo/util.ts +++ b/src/state/messages/convo/util.ts @@ -8,17 +8,21 @@ import { } from './types' /** - * Checks if a `Convo` has a `status` that is "active", meaning the chat is - * loaded and ready to be used, or its in a suspended or background state, and - * ready for resumption. + * States where the convo is ready to be used - either ready, or backgrounded/suspended + * and ready to be resumed */ -export function isConvoActive( - convo: ConvoState, -): convo is +export type ActiveConvoStates = | ConvoStateReady | ConvoStateBackgrounded | ConvoStateSuspended - | ConvoStateDisabled { + | ConvoStateDisabled + +/** + * Checks if a `Convo` has a `status` that is "active", meaning the chat is + * loaded and ready to be used, or its in a suspended or background state, and + * ready for resumption. + */ +export function isConvoActive(convo: ConvoState): convo is ActiveConvoStates { return ( convo.status === ConvoStatus.Ready || convo.status === ConvoStatus.Backgrounded || diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts new file mode 100644 index 000000000..82acb33c8 --- /dev/null +++ b/src/state/queries/messages/accept-conversation.ts @@ -0,0 +1,135 @@ +import {ChatBskyConvoAcceptConvo, ChatBskyConvoListConvos} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {DM_SERVICE_HEADERS} from './const' +import { + RQKEY as CONVO_LIST_KEY, + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, +} from './list-conversations' + +export function useAcceptConversation( + convoId: string, + { + onSuccess, + onMutate, + onError, + }: { + onMutate?: () => void + onSuccess?: (data: ChatBskyConvoAcceptConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + const {data} = await agent.chat.bsky.convo.acceptConvo( + {convoId}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + onMutate: () => { + let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = [] + let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = [] + let convoBeingAccepted: + | ChatBskyConvoListConvos.OutputSchema['convos'][number] + | undefined + queryClient.setQueryData( + CONVO_LIST_KEY('request'), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + prevInboxPages = old.pages + return { + ...old, + pages: old.pages.map(page => { + const found = page.convos.find(convo => convo.id === convoId) + if (found) { + convoBeingAccepted = found + return { + ...page, + convos: page.convos.filter(convo => convo.id !== convoId), + } + } + return page + }), + } + }, + ) + queryClient.setQueryData( + CONVO_LIST_KEY('accepted'), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + prevAcceptedPages = old.pages + if (convoBeingAccepted) { + return { + ...old, + pages: [ + { + ...old.pages[0], + convos: [ + { + ...convoBeingAccepted, + status: 'accepted', + }, + ...old.pages[0].convos, + ], + }, + ...old.pages.slice(1), + ], + } + } else { + return old + } + }, + ) + onMutate?.() + return {prevAcceptedPages, prevInboxPages} + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + onSuccess?.(data) + }, + onError: (error, _, context) => { + logger.error(error) + queryClient.setQueryData( + CONVO_LIST_KEY('accepted'), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevAcceptedPages || old.pages, + } + }, + ) + queryClient.setQueryData( + CONVO_LIST_KEY('request'), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevInboxPages || old.pages, + } + }, + ) + queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index 260524524..de5a90571 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -13,7 +13,7 @@ import {useAgent} from '#/state/session' import { ConvoListQueryData, getConvoFromQueryData, - RQKEY as LIST_CONVOS_KEY, + RQKEY_ROOT as LIST_CONVOS_KEY, } from './list-conversations' const RQKEY_ROOT = 'convo' @@ -76,34 +76,37 @@ export function useMarkAsReadMutation() { onSuccess(_, {convoId}) { if (!convoId) return - queryClient.setQueryData(LIST_CONVOS_KEY, (old: ConvoListQueryData) => { - if (!old) return old + queryClient.setQueriesData( + {queryKey: [LIST_CONVOS_KEY]}, + (old?: ConvoListQueryData) => { + if (!old) return old - const existingConvo = getConvoFromQueryData(convoId, old) + const existingConvo = getConvoFromQueryData(convoId, old) - if (existingConvo) { - return { - ...old, - pages: old.pages.map(page => { - return { - ...page, - convos: page.convos.map(convo => { - if (convo.id === convoId) { - return { - ...convo, - unreadCount: 0, + if (existingConvo) { + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.map(convo => { + if (convo.id === convoId) { + return { + ...convo, + unreadCount: 0, + } } - } - return convo - }), - } - }), + return convo + }), + } + }), + } + } else { + // If we somehow marked a convo as read that doesn't exist in the + // list, then we don't need to do anything. } - } else { - // If we somehow marked a convo as read that doesn't exist in the - // list, then we don't need to do anything. - } - }) + }, + ) }, }) } diff --git a/src/state/queries/messages/get-convo-availability.ts b/src/state/queries/messages/get-convo-availability.ts new file mode 100644 index 000000000..f545c3bba --- /dev/null +++ b/src/state/queries/messages/get-convo-availability.ts @@ -0,0 +1,25 @@ +import {useQuery} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' +import {useAgent} from '#/state/session' +import {STALE} from '..' + +const RQKEY_ROOT = 'convo-availability' +export const RQKEY = (did: string) => [RQKEY_ROOT, did] + +export function useGetConvoAvailabilityQuery(did: string) { + const agent = useAgent() + + return useQuery({ + queryKey: RQKEY(did), + queryFn: async () => { + const {data} = await agent.chat.bsky.convo.getConvoAvailability( + {members: [did]}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + staleTime: STALE.INFINITY, + }) +} diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts index 7979e0665..3f45c2328 100644 --- a/src/state/queries/messages/get-convo-for-members.ts +++ b/src/state/queries/messages/get-convo-for-members.ts @@ -1,14 +1,10 @@ import {ChatBskyConvoGetConvoForMembers} from '@atproto/api' -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {logger} from '#/logger' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' -import {STALE} from '..' -import {RQKEY as CONVO_KEY} from './conversation' - -const RQKEY_ROOT = 'convo-for-user' -export const RQKEY = (did: string) => [RQKEY_ROOT, did] +import {precacheConvoQuery} from './conversation' export function useGetConvoForMembers({ onSuccess, @@ -22,7 +18,7 @@ export function useGetConvoForMembers({ return useMutation({ mutationFn: async (members: string[]) => { - const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( + const {data} = await agent.chat.bsky.convo.getConvoForMembers( {members: members}, {headers: DM_SERVICE_HEADERS}, ) @@ -30,7 +26,7 @@ export function useGetConvoForMembers({ return data }, onSuccess: data => { - queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) + precacheConvoQuery(queryClient, data.convo) onSuccess?.(data) }, onError: error => { @@ -39,26 +35,3 @@ export function useGetConvoForMembers({ }, }) } - -/** - * Gets the conversation ID for a given DID. Returns null if it's not possible to message them. - */ -export function useMaybeConvoForUser(did: string) { - const agent = useAgent() - - return useQuery({ - queryKey: RQKEY(did), - queryFn: async () => { - const convo = await agent.api.chat.bsky.convo - .getConvoForMembers({members: [did]}, {headers: DM_SERVICE_HEADERS}) - .catch(() => ({success: null})) - - if (convo.success) { - return convo.data.convo - } else { - return null - } - }, - staleTime: STALE.INFINITY, - }) -} diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts index 21cd1f18c..b17e515be 100644 --- a/src/state/queries/messages/leave-conversation.ts +++ b/src/state/queries/messages/leave-conversation.ts @@ -1,3 +1,4 @@ +import {useMemo} from 'react' import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api' import { useMutation, @@ -8,7 +9,7 @@ import { import {logger} from '#/logger' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' -import {RQKEY as CONVO_LIST_KEY} from './list-conversations' +import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' const RQKEY_ROOT = 'leave-convo' export function RQKEY(convoId: string | undefined) { @@ -35,7 +36,7 @@ export function useLeaveConvo( mutationFn: async () => { if (!convoId) throw new Error('No convoId provided') - const {data} = await agent.api.chat.bsky.convo.leaveConvo( + const {data} = await agent.chat.bsky.convo.leaveConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) @@ -45,7 +46,7 @@ export function useLeaveConvo( onMutate: () => { let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] queryClient.setQueryData( - CONVO_LIST_KEY, + [CONVO_LIST_KEY], (old?: { pageParams: Array pages: Array @@ -67,13 +68,13 @@ export function useLeaveConvo( return {prevPages} }, onSuccess: data => { - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) onSuccess?.(data) }, onError: (error, _, context) => { logger.error(error) queryClient.setQueryData( - CONVO_LIST_KEY, + [CONVO_LIST_KEY], (old?: { pageParams: Array pages: Array @@ -85,7 +86,7 @@ export function useLeaveConvo( } }, ) - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) onError?.(error) }, }) @@ -105,5 +106,8 @@ export function useLeftConvos() { filters: {mutationKey: [RQKEY_ROOT], status: 'success'}, select: mutation => mutation.options.mutationKey?.[1] as string | undefined, }) - return [...pending, ...success].filter(id => id !== undefined) + return useMemo( + () => [...pending, ...success].filter(id => id !== undefined), + [pending, success], + ) } diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 8c9d6c429..f5fce6347 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -9,6 +9,7 @@ import { ChatBskyConvoDefs, ChatBskyConvoListConvos, moderateProfile, + ModerationOpts, } from '@atproto/api' import { InfiniteData, @@ -23,26 +24,39 @@ import {useMessagesEventBus} from '#/state/messages/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent, useSession} from '#/state/session' +import {useLeftConvos} from './leave-conversation' -export const RQKEY = ['convo-list'] +export const RQKEY_ROOT = 'convo-list' +export const RQKEY = ( + status: 'accepted' | 'request' | 'all', + readState: 'all' | 'unread' = 'all', +) => [RQKEY_ROOT, status, readState] type RQPageParam = string | undefined export function useListConvosQuery({ enabled, + status, + readState = 'all', }: { enabled?: boolean + status?: 'request' | 'accepted' + readState?: 'all' | 'unread' } = {}) { const agent = useAgent() return useInfiniteQuery({ enabled, - queryKey: RQKEY, + queryKey: RQKEY(status ?? 'all', readState), queryFn: async ({pageParam}) => { - const {data} = await agent.api.chat.bsky.convo.listConvos( - {cursor: pageParam, limit: 20}, + const {data} = await agent.chat.bsky.convo.listConvos( + { + limit: 20, + cursor: pageParam, + readState: readState === 'unread' ? 'unread' : undefined, + status, + }, {headers: DM_SERVICE_HEADERS}, ) - return data }, initialPageParam: undefined as RQPageParam, @@ -50,9 +64,10 @@ export function useListConvosQuery({ }) } -const ListConvosContext = createContext( - null, -) +const ListConvosContext = createContext<{ + accepted: ChatBskyConvoDefs.ConvoView[] + request: ChatBskyConvoDefs.ConvoView[] +} | null>(null) export function useListConvos() { const ctx = useContext(ListConvosContext) @@ -62,12 +77,13 @@ export function useListConvos() { return ctx } +const empty = {accepted: [], request: []} export function ListConvosProvider({children}: {children: React.ReactNode}) { const {hasSession} = useSession() if (!hasSession) { return ( - + {children} ) @@ -81,20 +97,23 @@ export function ListConvosProviderInner({ }: { children: React.ReactNode }) { - const {refetch, data} = useListConvosQuery() + const {refetch, data} = useListConvosQuery({readState: 'unread'}) const messagesBus = useMessagesEventBus() const queryClient = useQueryClient() const {currentConvoId} = useCurrentConvoId() const {currentAccount} = useSession() + const leftConvos = useLeftConvos() - const debouncedRefetch = useMemo( - () => - throttle(refetch, 500, { - leading: true, - trailing: true, - }), - [refetch], - ) + const debouncedRefetch = useMemo(() => { + const refetchAndInvalidate = () => { + refetch() + queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) + } + return throttle(refetchAndInvalidate, 500, { + leading: true, + trailing: true, + }) + }, [refetch, queryClient]) useEffect(() => { const unsub = messagesBus.on( @@ -105,69 +124,159 @@ export function ListConvosProviderInner({ if (ChatBskyConvoDefs.isLogBeginConvo(log)) { debouncedRefetch() } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => - optimisticDelete(log.convoId, old), + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), ) } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => { - if ( - (ChatBskyConvoDefs.isDeletedMessageView(log.message) || - ChatBskyConvoDefs.isMessageView(log.message)) && - (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) || - ChatBskyConvoDefs.isMessageView(convo.lastMessage)) - ) { - return log.message.id === convo.lastMessage.id - ? { - ...convo, - rev: log.rev, - lastMessage: log.message, - } - : convo - } else { - return convo - } - }), + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(log.convoId, old, convo => { + if ( + (ChatBskyConvoDefs.isDeletedMessageView(log.message) || + ChatBskyConvoDefs.isMessageView(log.message)) && + (ChatBskyConvoDefs.isDeletedMessageView( + convo.lastMessage, + ) || + ChatBskyConvoDefs.isMessageView(convo.lastMessage)) + ) { + return log.message.id === convo.lastMessage.id + ? { + ...convo, + rev: log.rev, + lastMessage: log.message, + } + : convo + } else { + return convo + } + }), ) } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) { // Store in a new var to avoid TS errors due to closures. const logRef: ChatBskyConvoDefs.LogCreateMessage = log - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { - if (!old) return old + // Get all matching queries + const queries = queryClient.getQueriesData({ + queryKey: [RQKEY_ROOT], + }) - function updateConvo(convo: ChatBskyConvoDefs.ConvoView) { - let unreadCount = convo.unreadCount - if (convo.id !== currentConvoId) { - if ( - ChatBskyConvoDefs.isMessageView(logRef.message) || - ChatBskyConvoDefs.isDeletedMessageView(logRef.message) - ) { - if (logRef.message.sender.did !== currentAccount?.did) { - unreadCount++ + // Check if convo exists in any query + let foundConvo: ChatBskyConvoDefs.ConvoView | null = null + for (const [_key, query] of queries) { + if (!query) continue + const convo = getConvoFromQueryData(logRef.convoId, query) + if (convo) { + foundConvo = convo + break + } + } + + if (!foundConvo) { + // Convo not found, trigger refetch + debouncedRefetch() + return + } + + // Update the convo + const updatedConvo = { + ...foundConvo, + rev: logRef.rev, + lastMessage: logRef.message, + unreadCount: + foundConvo.id !== currentConvoId + ? (ChatBskyConvoDefs.isMessageView(logRef.message) || + ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) && + logRef.message.sender.did !== currentAccount?.did + ? foundConvo.unreadCount + 1 + : foundConvo.unreadCount + : 0, + } + + function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) { + return convo.filter(c => c.id !== logRef.convoId) + } + + // Update all matching queries + function updateFn(old?: ConvoListQueryData) { + if (!old) return old + return { + ...old, + pages: old.pages.map((page, i) => { + if (i === 0) { + return { + ...page, + convos: [ + updatedConvo, + ...filterConvoFromPage(page.convos), + ], } } - } else { - unreadCount = 0 - } - - return { + return { + ...page, + convos: filterConvoFromPage(page.convos), + } + }), + } + } + // always update the unread one + queryClient.setQueriesData( + {queryKey: RQKEY('all', 'unread')}, + (old?: ConvoListQueryData) => + old + ? updateFn(old) + : ({ + pageParams: [undefined], + pages: [{convos: [updatedConvo], cursor: undefined}], + } satisfies ConvoListQueryData), + ) + // update the other ones based on status of the incoming message + if (updatedConvo.status === 'accepted') { + queryClient.setQueriesData( + {queryKey: RQKEY('accepted')}, + updateFn, + ) + } else if (updatedConvo.status === 'request') { + queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn) + } + } else if (ChatBskyConvoDefs.isLogReadMessage(log)) { + const logRef: ChatBskyConvoDefs.LogReadMessage = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ ...convo, + unreadCount: 0, rev: logRef.rev, - lastMessage: logRef.message, - unreadCount, + })), + ) + } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { + const logRef: ChatBskyConvoDefs.LogAcceptConvo = log + const requests = queryClient.getQueryData( + RQKEY('request'), + ) + if (!requests) { + debouncedRefetch() + return + } + const acceptedConvo = getConvoFromQueryData(log.convoId, requests) + if (!acceptedConvo) { + debouncedRefetch() + return + } + queryClient.setQueryData( + RQKEY('request'), + (old?: ConvoListQueryData) => + optimisticDelete(logRef.convoId, old), + ) + queryClient.setQueriesData( + {queryKey: RQKEY('accepted')}, + (old?: ConvoListQueryData) => { + if (!old) { + debouncedRefetch() + return old } - } - - function filterConvoFromPage( - convo: ChatBskyConvoDefs.ConvoView[], - ) { - return convo.filter(c => c.id !== logRef.convoId) - } - - const existingConvo = getConvoFromQueryData(logRef.convoId, old) - - if (existingConvo) { return { ...old, pages: old.pages.map((page, i) => { @@ -175,26 +284,38 @@ export function ListConvosProviderInner({ return { ...page, convos: [ - updateConvo(existingConvo), - ...filterConvoFromPage(page.convos), + {...acceptedConvo, status: 'accepted'}, + ...page.convos, ], } } - return { - ...page, - convos: filterConvoFromPage(page.convos), - } + return page }), } - } else { - /** - * We received a message from an conversation old enough that - * it doesn't exist in the query cache, meaning we need to - * refetch and bump the old convo to the top. - */ - debouncedRefetch() - } - }) + }, + ) + } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) { + const logRef: ChatBskyConvoDefs.LogMuteConvo = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ + ...convo, + muted: true, + rev: logRef.rev, + })), + ) + } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) { + const logRef: ChatBskyConvoDefs.LogUnmuteConvo = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ + ...convo, + muted: false, + rev: logRef.rev, + })), + ) } } }, @@ -208,15 +329,21 @@ export function ListConvosProviderInner({ }, [ messagesBus, currentConvoId, - refetch, queryClient, currentAccount?.did, debouncedRefetch, ]) const ctx = useMemo(() => { - return data?.pages.flatMap(page => page.convos) ?? [] - }, [data]) + const convos = + data?.pages + .flatMap(page => page.convos) + .filter(convo => !leftConvos.includes(convo.id)) ?? [] + return { + accepted: convos.filter(conv => conv.status === 'accepted'), + request: convos.filter(conv => conv.status === 'request'), + } + }, [data, leftConvos]) return ( @@ -228,38 +355,76 @@ export function ListConvosProviderInner({ export function useUnreadMessageCount() { const {currentConvoId} = useCurrentConvoId() const {currentAccount} = useSession() - const convos = useListConvos() + const {accepted, request} = useListConvos() const moderationOpts = useModerationOpts() - const count = useMemo(() => { - return ( - convos - .filter(convo => convo.id !== currentConvoId) - .reduce((acc, convo) => { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherMember || !moderationOpts) return acc - - const moderation = moderateProfile(otherMember, moderationOpts) - const shouldIgnore = - convo.muted || - moderation.blocked || - otherMember.did === 'missing.invalid' - const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 - - return acc + unreadCount - }, 0) ?? 0 + return useMemo<{ + count: number + numUnread?: string + hasNew: boolean + }>(() => { + const acceptedCount = calculateCount( + accepted, + currentAccount?.did, + currentConvoId, + moderationOpts, ) - }, [convos, currentAccount?.did, currentConvoId, moderationOpts]) - - return useMemo(() => { - return { - count, - numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined, + const requestCount = calculateCount( + request, + currentAccount?.did, + currentConvoId, + moderationOpts, + ) + if (acceptedCount > 0) { + const total = acceptedCount + Math.min(requestCount, 1) + return { + count: total, + numUnread: total > 10 ? '10+' : String(total), + // only needed when numUnread is undefined + hasNew: false, + } + } else if (requestCount > 0) { + return { + count: 1, + numUnread: undefined, + hasNew: true, + } + } else { + return { + count: 0, + numUnread: undefined, + hasNew: false, + } } - }, [count]) + }, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts]) +} + +function calculateCount( + convos: ChatBskyConvoDefs.ConvoView[], + currentAccountDid: string | undefined, + currentConvoId: string | undefined, + moderationOpts: ModerationOpts | undefined, +) { + return ( + convos + .filter(convo => convo.id !== currentConvoId) + .reduce((acc, convo) => { + const otherMember = convo.members.find( + member => member.did !== currentAccountDid, + ) + + if (!otherMember || !moderationOpts) return acc + + const moderation = moderateProfile(otherMember, moderationOpts) + const shouldIgnore = + convo.muted || + moderation.blocked || + otherMember.handle === 'missing.invalid' + const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 + + return acc + unreadCount + }, 0) ?? 0 + ) } export type ConvoListQueryData = { @@ -272,12 +437,16 @@ export function useOnMarkAsRead() { return useCallback( (chatId: string) => { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { - return optimisticUpdate(chatId, old, convo => ({ - ...convo, - unreadCount: 0, - })) - }) + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => { + if (!old) return old + return optimisticUpdate(chatId, old, convo => ({ + ...convo, + unreadCount: 0, + })) + }, + ) }, [queryClient], ) @@ -285,10 +454,12 @@ export function useOnMarkAsRead() { function optimisticUpdate( chatId: string, - old: ConvoListQueryData, - updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, + old?: ConvoListQueryData, + updateFn?: ( + convo: ChatBskyConvoDefs.ConvoView, + ) => ChatBskyConvoDefs.ConvoView, ) { - if (!old) return old + if (!old || !updateFn) return old return { ...old, @@ -301,7 +472,7 @@ function optimisticUpdate( } } -function optimisticDelete(chatId: string, old: ConvoListQueryData) { +function optimisticDelete(chatId: string, old?: ConvoListQueryData) { if (!old) return old return { @@ -331,7 +502,7 @@ export function* findAllProfilesInQueryData( const queryDatas = queryClient.getQueriesData< InfiniteData >({ - queryKey: RQKEY, + queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) { diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts index f32d02229..da9644145 100644 --- a/src/state/queries/messages/mute-conversation.ts +++ b/src/state/queries/messages/mute-conversation.ts @@ -8,7 +8,7 @@ import {InfiniteData, useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' import {RQKEY as CONVO_KEY} from './conversation' -import {RQKEY as CONVO_LIST_KEY} from './list-conversations' +import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' export function useMuteConvo( convoId: string | undefined, @@ -53,7 +53,7 @@ export function useMuteConvo( ) queryClient.setQueryData< InfiniteData - >(CONVO_LIST_KEY, prev => { + >([CONVO_LIST_KEY], prev => { if (!prev?.pages) return return { ...prev, diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts new file mode 100644 index 000000000..72fa65ee6 --- /dev/null +++ b/src/state/queries/messages/update-all-read.ts @@ -0,0 +1,105 @@ +import {ChatBskyConvoListConvos} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' +import {useAgent} from '#/state/session' +import {RQKEY as CONVO_LIST_KEY} from './list-conversations' + +export function useUpdateAllRead( + status: 'accepted' | 'request', + { + onSuccess, + onMutate, + onError, + }: { + onMutate?: () => void + onSuccess?: () => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + const {data} = await agent.chat.bsky.convo.updateAllRead( + {status}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + + return data + }, + onMutate: () => { + let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] + queryClient.setQueryData( + CONVO_LIST_KEY(status), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + prevPages = old.pages + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.map(convo => { + return { + ...convo, + unreadCount: 0, + } + }), + } + }), + } + }, + ) + // remove unread convos from the badge query + queryClient.setQueryData( + CONVO_LIST_KEY('all', 'unread'), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.filter(convo => convo.status !== status), + } + }), + } + }, + ) + onMutate?.() + return {prevPages} + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + onSuccess?.() + }, + onError: (error, _, context) => { + logger.error(error) + queryClient.setQueryData( + CONVO_LIST_KEY(status), + (old?: { + pageParams: Array + pages: Array + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevPages || old.pages, + } + }, + ) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 2c98df634..227ca9d66 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -37,7 +37,7 @@ import { ProgressGuideAction, useProgressGuideControls, } from '../shell/progress-guide' -import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-conversations' +import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' @@ -456,7 +456,7 @@ export function useProfileBlockMutationQueue( updateProfileShadow(queryClient, did, { blockingUri: finalBlockingUri, }) - queryClient.invalidateQueries({queryKey: RQKEY_LIST_CONVOS}) + queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) }, }) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 386342103..e19eb06dc 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -264,13 +264,7 @@ let UserAvatar = ({ onLoad={onLoad} /> )} - + {alert} ) : ( diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 7b760b069..822547d93 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -199,6 +199,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { } onPress={onPressMessages} notificationCount={numUnreadMessages.numUnread} + hasNew={numUnreadMessages.hasNew} accessible={true} accessibilityRole="tab" accessibilityLabel={_(msg`Chat`)} diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index d80914d09..62c677ced 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -14,7 +14,6 @@ export const styles = StyleSheet.create({ paddingRight: 10, }, bottomBarWeb: { - // @ts-ignore web-only position: 'fixed', }, ctrl: { @@ -46,7 +45,7 @@ export const styles = StyleSheet.create({ }, hasNewBadge: { position: 'absolute', - left: '52%', + left: '54%', marginLeft: 4, top: 10, width: 8, diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index d29649c8b..8c64f81a8 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -112,11 +112,8 @@ export function BottomBarWeb() { 0 - ? unreadMessageCount.numUnread - : undefined - }> + notificationCount={unreadMessageCount.numUnread} + hasNew={unreadMessageCount.hasNew}> {({isActive}) => { const Icon = isActive ? MessageFilled : Message return ( diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 59055c6dc..23e1d8ea2 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -423,12 +423,12 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) { backgroundColor: t.palette.primary_500, width: 8, height: 8, - right: -1, - top: -3, + right: -2, + top: -4, }, leftNavMinimal && { - right: 6, - top: 4, + right: 4, + top: 2, }, ]} /> @@ -520,6 +520,7 @@ function ChatNavItem() { } -- cgit 1.4.1