diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-03-04 13:54:19 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-03-04 05:54:19 -0800 |
commit | c995eb2f2fa3e73dcc6943078c85cd6a68f5370b (patch) | |
tree | 2dfea8ae6e4d86a77a90c72663b22441ca407159 /src | |
parent | 5c14f695660dcbf815a584d9d3bb037171dd0c14 (diff) | |
download | voidsky-c995eb2f2fa3e73dcc6943078c85cd6a68f5370b.tar.zst |
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 <mozzius@protonmail.com> --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
39 files changed, 1799 insertions, 340 deletions
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' @@ -412,6 +413,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { options={{title: title(msg`Chat settings`), requireAuth: true}} /> <Stack.Screen + name="MessagesInbox" + getComponent={() => MessagesInboxScreen} + options={{title: title(msg`Chat request inbox`), requireAuth: true}} + /> + <Stack.Screen name="NotificationSettings" getComponent={() => NotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} 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({ </View> ) } + +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 ( + <AvatarStack + numPending={profiles.length} + profiles={data?.profiles || []} + size={size} + backgroundColor={backgroundColor} + /> + ) +} 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<Map<string, AppBskyActorDefs.KnownFollowers>>( new Map(), @@ -64,11 +66,12 @@ export function KnownFollowers({ moderationOpts={moderationOpts} onLinkPress={onLinkPress} minimal={minimal} + showIfEmpty={showIfEmpty} /> ) } - return null + return <EmptyFallback show={showIfEmpty} /> } 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 <EmptyFallback show={showIfEmpty} /> 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({ </Link> ) } + +function EmptyFallback({show}: {show?: boolean}) { + const t = useTheme() + + if (!show) return null + + return ( + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>Not followed by anyone you're following</Trans> + </Text> + ) +} 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<NavigationProp>() @@ -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 ( <> <Button diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 7c35c30ba..8da8c015f 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -53,10 +53,10 @@ export let MessagesListHeader = ({ }, [moderation]) const onPressBack = useCallback(() => { - if (isWeb) { - navigation.replace('Messages', {}) - } else { + if (navigation.canGoBack()) { navigation.goBack() + } else { + navigation.navigate('Messages', {}) } }, [navigation]) diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx index 71cca897a..c1ea854f9 100644 --- a/src/components/dms/ReportDialog.tsx +++ b/src/components/dms/ReportDialog.tsx @@ -311,6 +311,19 @@ function DoneStep({ }, }) + let btnText = _(msg`Done`) + let toastMsg: string | undefined + if (actions.includes('leave') && actions.includes('block')) { + btnText = _(msg`Block and Delete`) + toastMsg = _(msg`Conversation deleted`) + } else if (actions.includes('leave')) { + btnText = _(msg`Delete Conversation`) + toastMsg = _(msg`Conversation deleted`) + } else if (actions.includes('block')) { + btnText = _(msg`Block User`) + toastMsg = _(msg`User blocked`) + } + const onPressPrimaryAction = () => { control.close(() => { if (actions.includes('block')) { @@ -319,18 +332,12 @@ function DoneStep({ if (actions.includes('leave')) { leaveConvo() } + if (toastMsg) { + Toast.show(toastMsg, 'check') + } }) } - let btnText = _(msg`Done`) - if (actions.includes('leave') && actions.includes('block')) { - btnText = _(msg`Block and Delete`) - } else if (actions.includes('leave')) { - btnText = _(msg`Delete Conversation`) - } else if (actions.includes('block')) { - btnText = _(msg`Block User`) - } - return ( <View style={a.gap_2xl}> <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}> diff --git a/src/components/icons/CircleX.tsx b/src/components/icons/CircleX.tsx new file mode 100644 index 000000000..e840bd09e --- /dev/null +++ b/src/components/icons/CircleX.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleX_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 51f196d09..0e38c9262 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -56,8 +56,9 @@ export type CommonNavigatorParams = { Search: {q?: string} Hashtag: {tag: string; author?: string} Topic: {topic: string} - MessagesConversation: {conversation: string; embed?: string} + MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesSettings: undefined + MessagesInbox: undefined NotificationSettings: undefined Feeds: undefined Start: {name: string; rkey: string} diff --git a/src/routes.ts b/src/routes.ts index 568f88bb8..b6a11acbf 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -59,6 +59,7 @@ export const router = new Router({ // DMs Messages: '/messages', MessagesSettings: '/messages/settings', + MessagesInbox: '/messages/inbox', MessagesConversation: '/messages/:conversation', // starter packs Start: '/start/:name/:rkey', 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) 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<void> +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<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + 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<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + 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<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevAcceptedPages || old.pages, + } + }, + ) + queryClient.setQueryData( + CONVO_LIST_KEY('request'), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + 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<string | undefined> pages: Array<ChatBskyConvoListConvos.OutputSchema> @@ -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<string | undefined> pages: Array<ChatBskyConvoListConvos.OutputSchema> @@ -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<ChatBskyConvoDefs.ConvoView[] | null>( - 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 ( - <ListConvosContext.Provider value={[]}> + <ListConvosContext.Provider value={empty}> {children} </ListConvosContext.Provider> ) @@ -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<ConvoListQueryData>({ + 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<ConvoListQueryData>( + 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 ( <ListConvosContext.Provider value={ctx}> @@ -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<ChatBskyConvoListConvos.OutputSchema> >({ - 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<ChatBskyConvoListConvos.OutputSchema> - >(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<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + 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<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + 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<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + 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} /> )} - <MediaInsetBorder - style={[ - { - borderRadius: aviStyle.borderRadius, - }, - ]} - /> + <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} /> {alert} </View> ) : ( 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() { <NavItem routeName="Messages" href="/messages" - notificationCount={ - unreadMessageCount.count > 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() { <NavItem href="/messages" count={numUnreadMessages.numUnread} + hasNew={numUnreadMessages.hasNew} icon={ <Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} /> } |