deer social fork for personal usage. but you might see a use idk. github mirror

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>

authored by samuel.fm surfdude29 samuel.fm Eric Bailey and committed by GitHub c995eb2f 5c14f695

+1
assets/icons/circleX_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+6
src/Navigation.tsx
··· 69 69 import HashtagScreen from '#/screens/Hashtag' 70 70 import {MessagesScreen} from '#/screens/Messages/ChatList' 71 71 import {MessagesConversationScreen} from '#/screens/Messages/Conversation' 72 + import {MessagesInboxScreen} from '#/screens/Messages/Inbox' 72 73 import {MessagesSettingsScreen} from '#/screens/Messages/Settings' 73 74 import {ModerationScreen} from '#/screens/Moderation' 74 75 import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' ··· 410 411 name="MessagesSettings" 411 412 getComponent={() => MessagesSettingsScreen} 412 413 options={{title: title(msg`Chat settings`), requireAuth: true}} 414 + /> 415 + <Stack.Screen 416 + name="MessagesInbox" 417 + getComponent={() => MessagesInboxScreen} 418 + options={{title: title(msg`Chat request inbox`), requireAuth: true}} 413 419 /> 414 420 <Stack.Screen 415 421 name="NotificationSettings"
+15
src/alf/atoms.ts
··· 31 31 right: 0, 32 32 bottom: 0, 33 33 }, 34 + top_0: { 35 + top: 0, 36 + }, 37 + right_0: { 38 + right: 0, 39 + }, 40 + bottom_0: { 41 + bottom: 0, 42 + }, 43 + left_0: { 44 + left: 0, 45 + }, 34 46 z_10: { 35 47 zIndex: 10, 36 48 }, ··· 93 105 /* 94 106 * Border radius 95 107 */ 108 + rounded_0: { 109 + borderRadius: 0, 110 + }, 96 111 rounded_2xs: { 97 112 borderRadius: tokens.borderRadius._2xs, 98 113 },
+41 -11
src/components/AvatarStack.tsx
··· 1 1 import {View} from 'react-native' 2 2 import {moderateProfile} from '@atproto/api' 3 3 4 + import {logger} from '#/logger' 4 5 import {useModerationOpts} from '#/state/preferences/moderation-opts' 5 6 import {useProfilesQuery} from '#/state/queries/profile' 6 7 import {UserAvatar} from '#/view/com/util/UserAvatar' 7 8 import {atoms as a, useTheme} from '#/alf' 9 + import * as bsky from '#/types/bsky' 8 10 9 11 export function AvatarStack({ 10 12 profiles, 11 13 size = 26, 14 + numPending, 15 + backgroundColor, 12 16 }: { 13 - profiles: string[] 17 + profiles: bsky.profile.AnyProfileView[] 14 18 size?: number 19 + numPending?: number 20 + backgroundColor?: string 15 21 }) { 16 22 const halfSize = size / 2 17 - const {data, error} = useProfilesQuery({handles: profiles}) 18 23 const t = useTheme() 19 24 const moderationOpts = useModerationOpts() 20 25 21 - if (error) { 22 - console.error(error) 23 - return null 24 - } 25 - 26 - const isPending = !data || !moderationOpts 26 + const isPending = (numPending && profiles.length === 0) || !moderationOpts 27 27 28 28 const items = isPending 29 - ? Array.from({length: profiles.length}).map((_, i) => ({ 29 + ? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({ 30 30 key: i, 31 31 profile: null, 32 32 moderation: null, 33 33 })) 34 - : data.profiles.map(item => ({ 34 + : profiles.map(item => ({ 35 35 key: item.did, 36 36 profile: item, 37 37 moderation: moderateProfile(item, moderationOpts), ··· 56 56 height: size, 57 57 left: i * -halfSize, 58 58 borderWidth: 1, 59 - borderColor: t.atoms.bg.backgroundColor, 59 + borderColor: backgroundColor ?? t.atoms.bg.backgroundColor, 60 60 borderRadius: 999, 61 61 zIndex: 3 - i, 62 62 }, ··· 74 74 </View> 75 75 ) 76 76 } 77 + 78 + export function AvatarStackWithFetch({ 79 + profiles, 80 + size, 81 + backgroundColor, 82 + }: { 83 + profiles: string[] 84 + size?: number 85 + backgroundColor?: string 86 + }) { 87 + const {data, error} = useProfilesQuery({handles: profiles}) 88 + 89 + if (error) { 90 + if (error.name !== 'AbortError') { 91 + logger.error('Error fetching profiles for AvatarStack', { 92 + safeMessage: error, 93 + }) 94 + } 95 + return null 96 + } 97 + 98 + return ( 99 + <AvatarStack 100 + numPending={profiles.length} 101 + profiles={data?.profiles || []} 102 + size={size} 103 + backgroundColor={backgroundColor} 104 + /> 105 + ) 106 + }
+20 -9
src/components/KnownFollowers.tsx
··· 33 33 moderationOpts, 34 34 onLinkPress, 35 35 minimal, 36 + showIfEmpty, 36 37 }: { 37 38 profile: bsky.profile.AnyProfileView 38 39 moderationOpts: ModerationOpts 39 40 onLinkPress?: LinkProps['onPress'] 40 41 minimal?: boolean 42 + showIfEmpty?: boolean 41 43 }) { 42 44 const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( 43 45 new Map(), ··· 64 66 moderationOpts={moderationOpts} 65 67 onLinkPress={onLinkPress} 66 68 minimal={minimal} 69 + showIfEmpty={showIfEmpty} 67 70 /> 68 71 ) 69 72 } 70 73 71 - return null 74 + return <EmptyFallback show={showIfEmpty} /> 72 75 } 73 76 74 77 function KnownFollowersInner({ ··· 77 80 cachedKnownFollowers, 78 81 onLinkPress, 79 82 minimal, 83 + showIfEmpty, 80 84 }: { 81 85 profile: bsky.profile.AnyProfileView 82 86 moderationOpts: ModerationOpts 83 87 cachedKnownFollowers: AppBskyActorDefs.KnownFollowers 84 88 onLinkPress?: LinkProps['onPress'] 85 89 minimal?: boolean 90 + showIfEmpty?: boolean 86 91 }) { 87 92 const t = useTheme() 88 93 const {_} = useLingui() 89 94 90 - const textStyle = [ 91 - a.flex_1, 92 - a.text_sm, 93 - a.leading_snug, 94 - t.atoms.text_contrast_medium, 95 - ] 95 + const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium] 96 96 97 97 const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { 98 98 const moderation = moderateProfile(f, moderationOpts) ··· 115 115 * We check above too, but here for clarity and a reminder to _check for 116 116 * valid indices_ 117 117 */ 118 - if (slice.length === 0) return null 118 + if (slice.length === 0) return <EmptyFallback show={showIfEmpty} /> 119 119 120 120 const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE 121 121 ··· 127 127 onPress={onLinkPress} 128 128 to={makeProfileLink(profile, 'known-followers')} 129 129 style={[ 130 - a.flex_1, 131 130 a.flex_row, 132 131 minimal ? a.gap_sm : a.gap_md, 133 132 a.align_center, ··· 243 242 </Link> 244 243 ) 245 244 } 245 + 246 + function EmptyFallback({show}: {show?: boolean}) { 247 + const t = useTheme() 248 + 249 + if (!show) return null 250 + 251 + return ( 252 + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 253 + <Trans>Not followed by anyone you're following</Trans> 254 + </Text> 255 + ) 256 + }
+5 -1
src/components/dms/LeaveConvoPrompt.tsx
··· 13 13 control, 14 14 convoId, 15 15 currentScreen, 16 + hasMessages = true, 16 17 }: { 17 18 control: DialogOuterProps['control'] 18 19 convoId: string 19 20 currentScreen: 'list' | 'conversation' 21 + hasMessages?: boolean 20 22 }) { 21 23 const {_} = useLingui() 22 24 const navigation = useNavigation<NavigationProp>() ··· 39 41 control={control} 40 42 title={_(msg`Leave conversation`)} 41 43 description={_( 42 - msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, 44 + hasMessages 45 + ? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.` 46 + : msg`Are you sure you want to leave this conversation?`, 43 47 )} 44 48 confirmButtonCta={_(msg`Leave`)} 45 49 confirmButtonColor="negative"
+33 -12
src/components/dms/MessageProfileButton.tsx
··· 8 8 import {useEmail} from '#/lib/hooks/useEmail' 9 9 import {NavigationProp} from '#/lib/routes/types' 10 10 import {logEvent} from '#/lib/statsig/statsig' 11 - import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' 11 + import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 12 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 13 + import * as Toast from '#/view/com/util/Toast' 12 14 import {atoms as a, useTheme} from '#/alf' 13 15 import {Button, ButtonIcon} from '#/components/Button' 16 + import {useDialogControl} from '#/components/Dialog' 17 + import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' 14 18 import {canBeMessaged} from '#/components/dms/util' 15 19 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' 16 - import {useDialogControl} from '../Dialog' 17 - import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog' 18 20 19 21 export function MessageProfileButton({ 20 22 profile, ··· 27 29 const {needsEmailVerification} = useEmail() 28 30 const verifyEmailControl = useDialogControl() 29 31 30 - const {data: convo, isPending} = useMaybeConvoForUser(profile.did) 32 + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 33 + const {mutate: initiateConvo} = useGetConvoForMembers({ 34 + onSuccess: ({convo}) => { 35 + logEvent('chat:open', {logContext: 'ProfileHeader'}) 36 + navigation.navigate('MessagesConversation', {conversation: convo.id}) 37 + }, 38 + onError: () => { 39 + Toast.show(_(msg`Failed to create conversation`)) 40 + }, 41 + }) 31 42 32 43 const onPress = React.useCallback(() => { 33 - if (!convo?.id) { 44 + if (!convoAvailability?.canChat) { 34 45 return 35 46 } 36 47 ··· 39 50 return 40 51 } 41 52 42 - if (convo && !convo.lastMessage) { 53 + if (convoAvailability.convo) { 54 + logEvent('chat:open', {logContext: 'ProfileHeader'}) 55 + navigation.navigate('MessagesConversation', { 56 + conversation: convoAvailability.convo.id, 57 + }) 58 + } else { 43 59 logEvent('chat:create', {logContext: 'ProfileHeader'}) 60 + initiateConvo([profile.did]) 44 61 } 45 - logEvent('chat:open', {logContext: 'ProfileHeader'}) 46 - 47 - navigation.navigate('MessagesConversation', {conversation: convo.id}) 48 - }, [needsEmailVerification, verifyEmailControl, convo, navigation]) 62 + }, [ 63 + needsEmailVerification, 64 + verifyEmailControl, 65 + navigation, 66 + profile.did, 67 + initiateConvo, 68 + convoAvailability, 69 + ]) 49 70 50 - if (isPending) { 71 + if (!convoAvailability) { 51 72 // show pending state based on declaration 52 73 if (canBeMessaged(profile)) { 53 74 return ( ··· 69 90 } 70 91 } 71 92 72 - if (convo) { 93 + if (convoAvailability.canChat) { 73 94 return ( 74 95 <> 75 96 <Button
+3 -3
src/components/dms/MessagesListHeader.tsx
··· 53 53 }, [moderation]) 54 54 55 55 const onPressBack = useCallback(() => { 56 - if (isWeb) { 57 - navigation.replace('Messages', {}) 58 - } else { 56 + if (navigation.canGoBack()) { 59 57 navigation.goBack() 58 + } else { 59 + navigation.navigate('Messages', {}) 60 60 } 61 61 }, [navigation]) 62 62
+16 -9
src/components/dms/ReportDialog.tsx
··· 311 311 }, 312 312 }) 313 313 314 + let btnText = _(msg`Done`) 315 + let toastMsg: string | undefined 316 + if (actions.includes('leave') && actions.includes('block')) { 317 + btnText = _(msg`Block and Delete`) 318 + toastMsg = _(msg`Conversation deleted`) 319 + } else if (actions.includes('leave')) { 320 + btnText = _(msg`Delete Conversation`) 321 + toastMsg = _(msg`Conversation deleted`) 322 + } else if (actions.includes('block')) { 323 + btnText = _(msg`Block User`) 324 + toastMsg = _(msg`User blocked`) 325 + } 326 + 314 327 const onPressPrimaryAction = () => { 315 328 control.close(() => { 316 329 if (actions.includes('block')) { ··· 319 332 if (actions.includes('leave')) { 320 333 leaveConvo() 321 334 } 335 + if (toastMsg) { 336 + Toast.show(toastMsg, 'check') 337 + } 322 338 }) 323 - } 324 - 325 - let btnText = _(msg`Done`) 326 - if (actions.includes('leave') && actions.includes('block')) { 327 - btnText = _(msg`Block and Delete`) 328 - } else if (actions.includes('leave')) { 329 - btnText = _(msg`Delete Conversation`) 330 - } else if (actions.includes('block')) { 331 - btnText = _(msg`Block User`) 332 339 } 333 340 334 341 return (
+5
src/components/icons/CircleX.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CircleX_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+2 -1
src/lib/routes/types.ts
··· 56 56 Search: {q?: string} 57 57 Hashtag: {tag: string; author?: string} 58 58 Topic: {topic: string} 59 - MessagesConversation: {conversation: string; embed?: string} 59 + MessagesConversation: {conversation: string; embed?: string; accept?: true} 60 60 MessagesSettings: undefined 61 + MessagesInbox: undefined 61 62 NotificationSettings: undefined 62 63 Feeds: undefined 63 64 Start: {name: string; rkey: string}
+1
src/routes.ts
··· 59 59 // DMs 60 60 Messages: '/messages', 61 61 MessagesSettings: '/messages/settings', 62 + MessagesInbox: '/messages/inbox', 62 63 MessagesConversation: '/messages/:conversation', 63 64 // starter packs 64 65 Start: '/start/:name/:rkey',
+77 -25
src/screens/Messages/ChatList.tsx
··· 1 1 import {useCallback, useEffect, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {useAnimatedRef} from 'react-native-reanimated' 4 - import {ChatBskyConvoDefs} from '@atproto/api' 4 + import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 import {useFocusEffect, useIsFocused} from '@react-navigation/native' ··· 18 18 import {useMessagesEventBus} from '#/state/messages/events' 19 19 import {useLeftConvos} from '#/state/queries/messages/leave-conversation' 20 20 import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 21 + import {useSession} from '#/state/session' 21 22 import {List, ListRef} from '#/view/com/util/List' 22 23 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 23 24 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 35 36 import {Loader} from '#/components/Loader' 36 37 import {Text} from '#/components/Typography' 37 38 import {ChatListItem} from './components/ChatListItem' 39 + import {InboxPreview} from './components/InboxPreview' 38 40 39 - type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> 41 + type ListItem = 42 + | { 43 + type: 'INBOX' 44 + count: number 45 + profiles: ChatBskyActorDefs.ProfileViewBasic[] 46 + } 47 + | { 48 + type: 'CONVERSATION' 49 + conversation: ChatBskyConvoDefs.ConvoView 50 + } 40 51 41 - function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { 42 - return <ChatListItem convo={item} /> 52 + function renderItem({item}: {item: ListItem}) { 53 + switch (item.type) { 54 + case 'INBOX': 55 + return <InboxPreview count={item.count} profiles={item.profiles} /> 56 + case 'CONVERSATION': 57 + return <ChatListItem convo={item.conversation} /> 58 + } 43 59 } 44 60 45 - function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { 46 - return item.id 61 + function keyExtractor(item: ListItem) { 62 + return item.type === 'INBOX' ? 'INBOX' : item.conversation.id 47 63 } 48 64 65 + type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> 49 66 export function MessagesScreen({navigation, route}: Props) { 50 67 const {_} = useLingui() 51 68 const t = useTheme() 69 + const {currentAccount} = useSession() 52 70 const newChatControl = useDialogControl() 53 71 const scrollElRef: ListRef = useAnimatedRef() 54 72 const pushToConversation = route.params?.pushToConversation ··· 94 112 isError, 95 113 error, 96 114 refetch, 97 - } = useListConvosQuery() 115 + } = useListConvosQuery({status: 'accepted'}) 116 + 117 + const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({ 118 + status: 'request', 119 + }) 98 120 99 121 useRefreshOnFocus(refetch) 122 + useRefreshOnFocus(refetchInbox) 100 123 101 124 const leftConvos = useLeftConvos() 102 125 126 + const inboxPreviewConvos = useMemo(() => { 127 + const inbox = 128 + inboxData?.pages 129 + .flatMap(page => page.convos) 130 + .filter( 131 + convo => 132 + !leftConvos.includes(convo.id) && 133 + !convo.muted && 134 + convo.unreadCount > 0, 135 + ) ?? [] 136 + 137 + return inbox 138 + .map(x => x.members.find(y => y.did !== currentAccount?.did)) 139 + .filter(x => !!x) 140 + }, [inboxData, leftConvos, currentAccount?.did]) 141 + 103 142 const conversations = useMemo(() => { 104 143 if (data?.pages) { 105 - return ( 106 - data.pages 107 - .flatMap(page => page.convos) 108 - // filter out convos that are actively being left 109 - .filter(convo => !leftConvos.includes(convo.id)) 110 - ) 144 + const conversations = data.pages 145 + .flatMap(page => page.convos) 146 + // filter out convos that are actively being left 147 + .filter(convo => !leftConvos.includes(convo.id)) 148 + 149 + return [ 150 + { 151 + type: 'INBOX', 152 + count: inboxPreviewConvos.length, 153 + profiles: inboxPreviewConvos.slice(0, 3), 154 + }, 155 + ...conversations.map( 156 + convo => ({type: 'CONVERSATION', conversation: convo} as const), 157 + ), 158 + ] satisfies ListItem[] 111 159 } 112 160 return [] 113 - }, [data, leftConvos]) 161 + }, [data, leftConvos, inboxPreviewConvos]) 114 162 115 163 const onRefresh = useCallback(async () => { 116 164 setIsPTRing(true) 117 165 try { 118 - await refetch() 166 + await Promise.all([refetch(), refetchInbox()]) 119 167 } catch (err) { 120 168 logger.error('Failed to refresh conversations', {message: err}) 121 169 } 122 170 setIsPTRing(false) 123 - }, [refetch, setIsPTRing]) 171 + }, [refetch, refetchInbox, setIsPTRing]) 124 172 125 173 const onEndReached = useCallback(async () => { 126 174 if (isFetchingNextPage || !hasNextPage || isError) return ··· 157 205 return listenSoftReset(onSoftReset) 158 206 }, [onSoftReset, isScreenFocused]) 159 207 160 - if (conversations.length < 1) { 208 + // Will always have 1 item - the inbox button 209 + if (conversations.length < 2) { 161 210 return ( 162 211 <Layout.Screen> 163 212 <Header newChatControl={newChatControl} /> ··· 173 222 <View style={[a.pt_3xl, a.align_center]}> 174 223 <CircleInfo 175 224 width={48} 176 - fill={t.atoms.border_contrast_low.borderColor} 225 + fill={t.atoms.text_contrast_low.color} 177 226 /> 178 227 <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> 179 228 <Trans>Whoops!</Trans> ··· 187 236 t.atoms.text_contrast_medium, 188 237 {maxWidth: 360}, 189 238 ]}> 190 - {cleanError(error)} 239 + {cleanError(error) || 240 + _(msg`Failed to load conversations`)} 191 241 </Text> 192 242 193 243 <Button 194 244 label={_(msg`Reload conversations`)} 195 - size="large" 196 - color="secondary" 245 + size="small" 246 + color="secondary_inverted" 197 247 variant="solid" 198 248 onPress={() => refetch()}> 199 249 <ButtonText> ··· 205 255 </> 206 256 ) : ( 207 257 <> 258 + <InboxPreview 259 + count={inboxPreviewConvos.length} 260 + profiles={inboxPreviewConvos} 261 + /> 208 262 <View style={[a.pt_3xl, a.align_center]}> 209 263 <Message width={48} fill={t.palette.primary_500} /> 210 264 <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> ··· 253 307 onRetry={fetchNextPage} 254 308 style={{borderColor: 'transparent'}} 255 309 hasNextPage={hasNextPage} 256 - showEndMessage={true} 257 - endMessageText={_(msg`No more conversations to show`)} 258 310 /> 259 311 } 260 312 onEndReachedThreshold={isNative ? 1.5 : 0} ··· 290 342 <> 291 343 <Layout.Header.Content> 292 344 <Layout.Header.TitleText> 293 - <Trans>Messages</Trans> 345 + <Trans>Chats</Trans> 294 346 </Layout.Header.TitleText> 295 347 </Layout.Header.Content> 296 348 ··· 314 366 <Layout.Header.MenuButton /> 315 367 <Layout.Header.Content> 316 368 <Layout.Header.TitleText> 317 - <Trans>Messages</Trans> 369 + <Trans>Chats</Trans> 318 370 </Layout.Header.TitleText> 319 371 </Layout.Header.Content> 320 372 <Layout.Header.Slot>{settingsLink}</Layout.Header.Slot>
+9 -1
src/screens/Messages/Conversation.tsx
··· 7 7 } from '@atproto/api' 8 8 import {msg} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' 10 - import {useFocusEffect, useNavigation} from '@react-navigation/native' 10 + import { 11 + RouteProp, 12 + useFocusEffect, 13 + useNavigation, 14 + useRoute, 15 + } from '@react-navigation/native' 11 16 import {NativeStackScreenProps} from '@react-navigation/native-stack' 12 17 13 18 import {useEmail} from '#/lib/hooks/useEmail' ··· 172 177 const {_} = useLingui() 173 178 const convoState = useConvo() 174 179 const navigation = useNavigation<NavigationProp>() 180 + const {params} = 181 + useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() 175 182 const verifyEmailControl = useDialogControl() 176 183 const {needsEmailVerification} = useEmail() 177 184 ··· 189 196 hasScrolled={hasScrolled} 190 197 setHasScrolled={setHasScrolled} 191 198 blocked={moderation?.blocked} 199 + hasAcceptOverride={!!params.accept} 192 200 footer={ 193 201 <MessagesListBlockedFooter 194 202 recipient={recipient}
+332
src/screens/Messages/Inbox.tsx
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 7 + import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' 8 + 9 + import {useAppState} from '#/lib/hooks/useAppState' 10 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 + import { 12 + CommonNavigatorParams, 13 + NativeStackScreenProps, 14 + NavigationProp, 15 + } from '#/lib/routes/types' 16 + import {cleanError} from '#/lib/strings/errors' 17 + import {logger} from '#/logger' 18 + import {isNative} from '#/platform/detection' 19 + import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' 20 + import {useMessagesEventBus} from '#/state/messages/events' 21 + import {useLeftConvos} from '#/state/queries/messages/leave-conversation' 22 + import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 23 + import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' 24 + import {FAB} from '#/view/com/util/fab/FAB' 25 + import {List} from '#/view/com/util/List' 26 + import * as Toast from '#/view/com/util/Toast' 27 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 28 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 + import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 30 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 31 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' 32 + import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 33 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 34 + import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 35 + import * as Layout from '#/components/Layout' 36 + import {ListFooter} from '#/components/Lists' 37 + import {Loader} from '#/components/Loader' 38 + import {Text} from '#/components/Typography' 39 + import {RequestListItem} from './components/RequestListItem' 40 + 41 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'> 42 + export function MessagesInboxScreen({}: Props) { 43 + const {gtTablet} = useBreakpoints() 44 + 45 + const listConvosQuery = useListConvosQuery({status: 'request'}) 46 + const {data} = listConvosQuery 47 + 48 + const leftConvos = useLeftConvos() 49 + 50 + const conversations = useMemo(() => { 51 + if (data?.pages) { 52 + const convos = data.pages 53 + .flatMap(page => page.convos) 54 + // filter out convos that are actively being left 55 + .filter(convo => !leftConvos.includes(convo.id)) 56 + 57 + return convos 58 + } 59 + return [] 60 + }, [data, leftConvos]) 61 + 62 + const hasUnreadConvos = useMemo(() => { 63 + return conversations.some(conversation => conversation.unreadCount > 0) 64 + }, [conversations]) 65 + 66 + return ( 67 + <Layout.Screen testID="messagesInboxScreen"> 68 + <Layout.Header.Outer> 69 + <Layout.Header.BackButton /> 70 + <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 71 + <Layout.Header.TitleText> 72 + <Trans>Chat requests</Trans> 73 + </Layout.Header.TitleText> 74 + </Layout.Header.Content> 75 + {hasUnreadConvos && gtTablet ? ( 76 + <MarkAsReadHeaderButton /> 77 + ) : ( 78 + <Layout.Header.Slot /> 79 + )} 80 + </Layout.Header.Outer> 81 + <RequestList 82 + listConvosQuery={listConvosQuery} 83 + conversations={conversations} 84 + hasUnreadConvos={hasUnreadConvos} 85 + /> 86 + </Layout.Screen> 87 + ) 88 + } 89 + 90 + function RequestList({ 91 + listConvosQuery, 92 + conversations, 93 + hasUnreadConvos, 94 + }: { 95 + listConvosQuery: UseInfiniteQueryResult< 96 + InfiniteData<ChatBskyConvoListConvos.OutputSchema>, 97 + Error 98 + > 99 + conversations: ChatBskyConvoDefs.ConvoView[] 100 + hasUnreadConvos: boolean 101 + }) { 102 + const {_} = useLingui() 103 + const t = useTheme() 104 + const navigation = useNavigation<NavigationProp>() 105 + 106 + // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) 107 + // but only when the screen is active 108 + const messagesBus = useMessagesEventBus() 109 + const state = useAppState() 110 + const isActive = state === 'active' 111 + useFocusEffect( 112 + useCallback(() => { 113 + if (isActive) { 114 + const unsub = messagesBus.requestPollInterval( 115 + MESSAGE_SCREEN_POLL_INTERVAL, 116 + ) 117 + return () => unsub() 118 + } 119 + }, [messagesBus, isActive]), 120 + ) 121 + 122 + const initialNumToRender = useInitialNumToRender({minItemHeight: 130}) 123 + const [isPTRing, setIsPTRing] = useState(false) 124 + 125 + const { 126 + isLoading, 127 + isFetchingNextPage, 128 + hasNextPage, 129 + fetchNextPage, 130 + isError, 131 + error, 132 + refetch, 133 + } = listConvosQuery 134 + 135 + useRefreshOnFocus(refetch) 136 + 137 + const onRefresh = useCallback(async () => { 138 + setIsPTRing(true) 139 + try { 140 + await refetch() 141 + } catch (err) { 142 + logger.error('Failed to refresh conversations', {message: err}) 143 + } 144 + setIsPTRing(false) 145 + }, [refetch, setIsPTRing]) 146 + 147 + const onEndReached = useCallback(async () => { 148 + if (isFetchingNextPage || !hasNextPage || isError) return 149 + try { 150 + await fetchNextPage() 151 + } catch (err) { 152 + logger.error('Failed to load more conversations', {message: err}) 153 + } 154 + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 155 + 156 + if (conversations.length < 1) { 157 + return ( 158 + <Layout.Center> 159 + {isLoading ? ( 160 + <View style={[a.align_center, a.pt_3xl, web({paddingTop: '10vh'})]}> 161 + <Loader size="xl" /> 162 + </View> 163 + ) : ( 164 + <> 165 + {isError ? ( 166 + <> 167 + <View style={[a.pt_3xl, a.align_center]}> 168 + <CircleInfoIcon 169 + width={48} 170 + fill={t.atoms.text_contrast_low.color} 171 + /> 172 + <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> 173 + <Trans>Whoops!</Trans> 174 + </Text> 175 + <Text 176 + style={[ 177 + a.text_md, 178 + a.pb_xl, 179 + a.text_center, 180 + a.leading_snug, 181 + t.atoms.text_contrast_medium, 182 + {maxWidth: 360}, 183 + ]}> 184 + {cleanError(error) || _(msg`Failed to load conversations`)} 185 + </Text> 186 + 187 + <Button 188 + label={_(msg`Reload conversations`)} 189 + size="small" 190 + color="secondary_inverted" 191 + variant="solid" 192 + onPress={() => refetch()}> 193 + <ButtonText> 194 + <Trans>Retry</Trans> 195 + </ButtonText> 196 + <ButtonIcon icon={RetryIcon} position="right" /> 197 + </Button> 198 + </View> 199 + </> 200 + ) : ( 201 + <> 202 + <View style={[a.pt_3xl, a.align_center]}> 203 + <MessageIcon width={48} fill={t.palette.primary_500} /> 204 + <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> 205 + <Trans comment="Title message shown in chat requests inbox when it's empty"> 206 + Inbox zero! 207 + </Trans> 208 + </Text> 209 + <Text 210 + style={[ 211 + a.text_md, 212 + a.pb_xl, 213 + a.text_center, 214 + a.leading_snug, 215 + t.atoms.text_contrast_medium, 216 + ]}> 217 + <Trans> 218 + You don't have any chat requests at the moment. 219 + </Trans> 220 + </Text> 221 + <Button 222 + variant="solid" 223 + color="secondary" 224 + size="small" 225 + label={_(msg`Go back`)} 226 + onPress={() => { 227 + if (navigation.canGoBack()) { 228 + navigation.goBack() 229 + } else { 230 + navigation.navigate('Messages', {animation: 'pop'}) 231 + } 232 + }}> 233 + <ButtonIcon icon={ArrowLeftIcon} /> 234 + <ButtonText> 235 + <Trans>Back to Chats</Trans> 236 + </ButtonText> 237 + </Button> 238 + </View> 239 + </> 240 + )} 241 + </> 242 + )} 243 + </Layout.Center> 244 + ) 245 + } 246 + 247 + return ( 248 + <> 249 + <List 250 + data={conversations} 251 + renderItem={renderItem} 252 + keyExtractor={keyExtractor} 253 + refreshing={isPTRing} 254 + onRefresh={onRefresh} 255 + onEndReached={onEndReached} 256 + ListFooterComponent={ 257 + <ListFooter 258 + isFetchingNextPage={isFetchingNextPage} 259 + error={cleanError(error)} 260 + onRetry={fetchNextPage} 261 + style={{borderColor: 'transparent'}} 262 + hasNextPage={hasNextPage} 263 + /> 264 + } 265 + onEndReachedThreshold={isNative ? 1.5 : 0} 266 + initialNumToRender={initialNumToRender} 267 + windowSize={11} 268 + desktopFixedHeight 269 + sideBorders={false} 270 + /> 271 + {hasUnreadConvos && <MarkAllReadFAB />} 272 + </> 273 + ) 274 + } 275 + 276 + function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { 277 + return item.id 278 + } 279 + 280 + function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { 281 + return <RequestListItem convo={item} /> 282 + } 283 + 284 + function MarkAllReadFAB() { 285 + const {_} = useLingui() 286 + const t = useTheme() 287 + const {mutate: markAllRead} = useUpdateAllRead('request', { 288 + onMutate: () => { 289 + Toast.show(_(msg`Marked all as read`), 'check') 290 + }, 291 + onError: () => { 292 + Toast.show(_(msg`Failed to mark all requests as read`), 'xmark') 293 + }, 294 + }) 295 + 296 + return ( 297 + <FAB 298 + testID="markAllAsReadFAB" 299 + onPress={() => markAllRead()} 300 + icon={<CheckIcon size="lg" fill={t.palette.white} />} 301 + accessibilityRole="button" 302 + accessibilityLabel={_(msg`Mark all as read`)} 303 + accessibilityHint="" 304 + /> 305 + ) 306 + } 307 + 308 + function MarkAsReadHeaderButton() { 309 + const {_} = useLingui() 310 + const {mutate: markAllRead} = useUpdateAllRead('request', { 311 + onMutate: () => { 312 + Toast.show(_(msg`Marked all as read`), 'check') 313 + }, 314 + onError: () => { 315 + Toast.show(_(msg`Failed to mark all requests as read`), 'xmark') 316 + }, 317 + }) 318 + 319 + return ( 320 + <Button 321 + label={_(msg`Mark all as read`)} 322 + size="small" 323 + color="secondary" 324 + variant="solid" 325 + onPress={() => markAllRead()}> 326 + <ButtonIcon icon={CheckIcon} /> 327 + <ButtonText> 328 + <Trans>Mark all as read</Trans> 329 + </ButtonText> 330 + </Button> 331 + ) 332 + }
+39 -26
src/screens/Messages/components/ChatListItem.tsx
··· 47 47 48 48 export let ChatListItem = ({ 49 49 convo, 50 + showMenu = true, 51 + children, 50 52 }: { 51 53 convo: ChatBskyConvoDefs.ConvoView 54 + showMenu?: boolean 55 + children?: React.ReactNode 52 56 }): React.ReactNode => { 53 57 const {currentAccount} = useSession() 54 58 const moderationOpts = useModerationOpts() ··· 66 70 convo={convo} 67 71 profile={otherUser} 68 72 moderationOpts={moderationOpts} 69 - /> 73 + showMenu={showMenu}> 74 + {children} 75 + </ChatListItemReady> 70 76 ) 71 77 } 72 78 ··· 76 82 convo, 77 83 profile: profileUnshadowed, 78 84 moderationOpts, 85 + showMenu, 86 + children, 79 87 }: { 80 88 convo: ChatBskyConvoDefs.ConvoView 81 89 profile: bsky.profile.AnyProfileView 82 90 moderationOpts: ModerationOpts 91 + showMenu?: boolean 92 + children?: React.ReactNode 83 93 }) { 84 94 const t = useTheme() 85 95 const {_} = useLingui() ··· 252 262 leftFirst: deleteAction, 253 263 } 254 264 265 + const hasUnread = convo.unreadCount > 0 && !isDeletedAccount 266 + 255 267 return ( 256 268 <GestureActionView actions={actions}> 257 269 <View ··· 305 317 a.py_md, 306 318 a.gap_md, 307 319 (hovered || pressed || focused) && t.atoms.bg_contrast_25, 308 - t.atoms.border_contrast_low, 309 320 ]}> 310 321 {/* Avatar goes here */} 311 322 <View style={{width: 52, height: 52}} /> ··· 376 387 style={[ 377 388 a.text_sm, 378 389 a.leading_snug, 379 - convo.unreadCount > 0 380 - ? a.font_bold 381 - : t.atoms.text_contrast_high, 390 + hasUnread ? a.font_bold : t.atoms.text_contrast_high, 382 391 isDimStyle && t.atoms.text_contrast_medium, 383 392 ]}> 384 393 {lastMessage} ··· 389 398 size="lg" 390 399 style={[a.pt_xs]} 391 400 /> 401 + 402 + {children} 392 403 </View> 393 404 394 - {convo.unreadCount > 0 && ( 405 + {hasUnread && ( 395 406 <View 396 407 style={[ 397 408 a.absolute, ··· 412 423 )} 413 424 </Link> 414 425 415 - <ConvoMenu 416 - convo={convo} 417 - profile={profile} 418 - control={menuControl} 419 - currentScreen="list" 420 - showMarkAsRead={convo.unreadCount > 0} 421 - hideTrigger={isNative} 422 - blockInfo={blockInfo} 423 - style={[ 424 - a.absolute, 425 - a.h_full, 426 - a.self_end, 427 - a.justify_center, 428 - { 429 - right: tokens.space.lg, 430 - opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, 431 - }, 432 - ]} 433 - latestReportableMessage={latestReportableMessage} 434 - /> 426 + {showMenu && ( 427 + <ConvoMenu 428 + convo={convo} 429 + profile={profile} 430 + control={menuControl} 431 + currentScreen="list" 432 + showMarkAsRead={convo.unreadCount > 0} 433 + hideTrigger={isNative} 434 + blockInfo={blockInfo} 435 + style={[ 436 + a.absolute, 437 + a.h_full, 438 + a.self_end, 439 + a.justify_center, 440 + { 441 + right: tokens.space.lg, 442 + opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, 443 + }, 444 + ]} 445 + latestReportableMessage={latestReportableMessage} 446 + /> 447 + )} 435 448 <LeaveConvoPrompt 436 449 control={leaveConvoControl} 437 450 convoId={convo.id}
+81
src/screens/Messages/components/ChatStatusInfo.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {ActiveConvoStates} from '#/state/messages/convo' 7 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 + import {useSession} from '#/state/session' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 11 + import {KnownFollowers} from '#/components/KnownFollowers' 12 + import {usePromptControl} from '#/components/Prompt' 13 + import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' 14 + 15 + export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { 16 + const t = useTheme() 17 + const {_} = useLingui() 18 + const moderationOpts = useModerationOpts() 19 + const {currentAccount} = useSession() 20 + const leaveConvoControl = usePromptControl() 21 + 22 + const onAcceptChat = useCallback(() => { 23 + convoState.markConvoAccepted() 24 + }, [convoState]) 25 + 26 + const otherUser = convoState.recipients.find( 27 + user => user.did !== currentAccount?.did, 28 + ) 29 + 30 + if (!moderationOpts) { 31 + return null 32 + } 33 + 34 + return ( 35 + <View style={[t.atoms.bg, a.p_lg, a.gap_md, a.align_center]}> 36 + {otherUser && ( 37 + <KnownFollowers 38 + profile={otherUser} 39 + moderationOpts={moderationOpts} 40 + showIfEmpty 41 + /> 42 + )} 43 + <View style={[a.flex_row, a.gap_md, a.w_full, otherUser && a.pt_sm]}> 44 + {otherUser && ( 45 + <RejectMenu 46 + label={_(msg`Block or report`)} 47 + convo={convoState.convo} 48 + profile={otherUser} 49 + color="negative" 50 + size="small" 51 + currentScreen="conversation" 52 + /> 53 + )} 54 + <DeleteChatButton 55 + label={_(msg`Delete`)} 56 + convo={convoState.convo} 57 + color="secondary" 58 + size="small" 59 + currentScreen="conversation" 60 + onPress={leaveConvoControl.open} 61 + /> 62 + <LeaveConvoPrompt 63 + convoId={convoState.convo.id} 64 + control={leaveConvoControl} 65 + currentScreen="conversation" 66 + hasMessages={false} 67 + /> 68 + </View> 69 + <View style={[a.w_full, a.flex_row]}> 70 + <AcceptChatButton 71 + onAcceptConvo={onAcceptChat} 72 + convo={convoState.convo} 73 + color="primary" 74 + variant="outline" 75 + size="small" 76 + currentScreen="conversation" 77 + /> 78 + </View> 79 + </View> 80 + ) 81 + }
+73
src/screens/Messages/components/InboxPreview.tsx
··· 1 + import {View} from 'react-native' 2 + import {ChatBskyActorDefs} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {AvatarStack} from '#/components/AvatarStack' 8 + import {ButtonIcon, ButtonText} from '#/components/Button' 9 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 10 + import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' 11 + import {Link} from '#/components/Link' 12 + 13 + export function InboxPreview({ 14 + profiles, 15 + }: // count, 16 + { 17 + profiles: ChatBskyActorDefs.ProfileViewBasic[] 18 + count: number 19 + }) { 20 + const {_} = useLingui() 21 + const t = useTheme() 22 + return ( 23 + <Link 24 + label={_(msg`Chat request inbox`)} 25 + style={[ 26 + a.flex_1, 27 + a.px_xl, 28 + a.py_sm, 29 + a.flex_row, 30 + a.align_center, 31 + a.gap_md, 32 + a.border_t, 33 + {marginTop: a.border_t.borderTopWidth * -1}, 34 + a.border_b, 35 + t.atoms.border_contrast_low, 36 + {minHeight: 44}, 37 + a.rounded_0, 38 + ]} 39 + to="/messages/inbox" 40 + color="secondary" 41 + variant="solid"> 42 + <View style={[a.relative]}> 43 + <ButtonIcon icon={EnvelopeIcon} size="lg" /> 44 + {profiles.length > 0 && ( 45 + <View 46 + style={[ 47 + a.absolute, 48 + a.rounded_full, 49 + a.z_20, 50 + { 51 + top: -4, 52 + right: -5, 53 + width: 10, 54 + height: 10, 55 + backgroundColor: t.palette.primary_500, 56 + }, 57 + ]} 58 + /> 59 + )} 60 + </View> 61 + <ButtonText 62 + style={[a.flex_1, a.font_bold, a.text_left]} 63 + numberOfLines={1}> 64 + <Trans>Chat requests</Trans> 65 + </ButtonText> 66 + <AvatarStack 67 + profiles={profiles} 68 + backgroundColor={t.atoms.bg_contrast_25.backgroundColor} 69 + /> 70 + <ButtonIcon icon={ArrowRightIcon} size="lg" /> 71 + </Link> 72 + ) 73 + }
+47 -21
src/screens/Messages/components/MessagesList.tsx
··· 9 9 useSharedValue, 10 10 } from 'react-native-reanimated' 11 11 import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' 12 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 13 12 import { 14 13 $Typed, 15 14 AppBskyEmbedRecord, ··· 17 16 RichText, 18 17 } from '@atproto/api' 19 18 20 - import {clamp} from '#/lib/numbers' 21 19 import {ScrollProvider} from '#/lib/ScrollContext' 22 20 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 23 21 import { ··· 31 29 import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' 32 30 import {useGetPost} from '#/state/queries/post' 33 31 import {useAgent} from '#/state/session' 32 + import {useShellLayout} from '#/state/shell/shell-layout' 34 33 import { 35 34 EmojiPicker, 36 35 EmojiPickerState, ··· 44 43 import {NewMessagesPill} from '#/components/dms/NewMessagesPill' 45 44 import {Loader} from '#/components/Loader' 46 45 import {Text} from '#/components/Typography' 46 + import {ChatStatusInfo} from './ChatStatusInfo' 47 47 import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' 48 48 49 49 function MaybeLoader({isLoading}: {isLoading: boolean}) { ··· 85 85 setHasScrolled, 86 86 blocked, 87 87 footer, 88 + hasAcceptOverride, 88 89 }: { 89 90 hasScrolled: boolean 90 91 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> 91 92 blocked?: boolean 92 93 footer?: React.ReactNode 94 + hasAcceptOverride?: boolean 93 95 }) { 94 96 const convoState = useConvoActive() 95 97 const agent = useAgent() ··· 242 244 ) 243 245 244 246 // -- Keyboard animation handling 245 - const {bottom: bottomInset} = useSafeAreaInsets() 246 - const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75) 247 + const {footerHeight} = useShellLayout() 247 248 248 249 const keyboardHeight = useSharedValue(0) 249 250 const keyboardIsOpening = useSharedValue(false) ··· 268 269 onMove: e => { 269 270 'worklet' 270 271 keyboardHeight.set(e.height) 271 - if (e.height > bottomOffset) { 272 + if (e.height > footerHeight.get()) { 272 273 scrollTo(flatListRef, 0, 1e7, false) 273 274 } 274 275 }, 275 276 onEnd: e => { 276 277 'worklet' 277 278 keyboardHeight.set(e.height) 278 - if (e.height > bottomOffset) { 279 + if (e.height > footerHeight.get()) { 279 280 scrollTo(flatListRef, 0, 1e7, false) 280 281 } 281 282 keyboardIsOpening.set(false) 282 283 }, 283 284 }, 284 - [bottomOffset], 285 + [footerHeight], 285 286 ) 286 287 287 288 const animatedListStyle = useAnimatedStyle(() => ({ 288 - marginBottom: Math.max(keyboardHeight.get(), bottomOffset), 289 + marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()), 289 290 })) 290 291 291 292 const animatedStickyViewStyle = useAnimatedStyle(() => ({ 292 - transform: [{translateY: -Math.max(keyboardHeight.get(), bottomOffset)}], 293 + transform: [ 294 + {translateY: -Math.max(keyboardHeight.get(), footerHeight.get())}, 295 + ], 293 296 })) 294 297 295 298 // -- Message sending ··· 437 440 ) : blocked ? ( 438 441 footer 439 442 ) : ( 440 - <> 441 - {isConvoActive(convoState) && 442 - !convoState.isFetchingHistory && 443 - convoState.items.length === 0 && <ChatEmptyPill />} 444 - <MessageInput 445 - onSendMessage={onSendMessage} 446 - hasEmbed={!!embedUri} 447 - setEmbed={setEmbed} 448 - openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> 449 - <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 450 - </MessageInput> 451 - </> 443 + isConvoActive(convoState) && 444 + !convoState.isFetchingHistory && ( 445 + <> 446 + {convoState.items.length === 0 ? ( 447 + <> 448 + <ChatEmptyPill /> 449 + <MessageInput 450 + onSendMessage={onSendMessage} 451 + hasEmbed={!!embedUri} 452 + setEmbed={setEmbed} 453 + openEmojiPicker={pos => 454 + setEmojiPickerState({isOpen: true, pos}) 455 + }> 456 + <MessageInputEmbed 457 + embedUri={embedUri} 458 + setEmbed={setEmbed} 459 + /> 460 + </MessageInput> 461 + </> 462 + ) : convoState.convo.status === 'request' && 463 + !hasAcceptOverride ? ( 464 + <ChatStatusInfo convoState={convoState} /> 465 + ) : ( 466 + <MessageInput 467 + onSendMessage={onSendMessage} 468 + hasEmbed={!!embedUri} 469 + setEmbed={setEmbed} 470 + openEmojiPicker={pos => 471 + setEmojiPickerState({isOpen: true, pos}) 472 + }> 473 + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> 474 + </MessageInput> 475 + )} 476 + </> 477 + ) 452 478 )} 453 479 </Animated.View> 454 480
+254
src/screens/Messages/components/RequestButtons.tsx
··· 1 + import {useCallback} from 'react' 2 + import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {StackActions, useNavigation} from '@react-navigation/native' 6 + import {useQueryClient} from '@tanstack/react-query' 7 + 8 + import {NavigationProp} from '#/lib/routes/types' 9 + import {useProfileShadow} from '#/state/cache/profile-shadow' 10 + import {useAcceptConversation} from '#/state/queries/messages/accept-conversation' 11 + import {precacheConvoQuery} from '#/state/queries/messages/conversation' 12 + import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 13 + import {useProfileBlockMutationQueue} from '#/state/queries/profile' 14 + import * as Toast from '#/view/com/util/Toast' 15 + import {atoms as a} from '#/alf' 16 + import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' 17 + import {useDialogControl} from '#/components/Dialog' 18 + import {ReportDialog} from '#/components/dms/ReportDialog' 19 + import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' 20 + import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 21 + import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' 22 + import {Loader} from '#/components/Loader' 23 + import * as Menu from '#/components/Menu' 24 + 25 + export function RejectMenu({ 26 + convo, 27 + profile, 28 + size = 'tiny', 29 + variant = 'outline', 30 + color = 'secondary', 31 + label, 32 + showDeleteConvo, 33 + currentScreen, 34 + ...props 35 + }: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & { 36 + label?: string 37 + convo: ChatBskyConvoDefs.ConvoView 38 + profile: ChatBskyActorDefs.ProfileViewBasic 39 + showDeleteConvo?: boolean 40 + currentScreen: 'list' | 'conversation' 41 + }) { 42 + const {_} = useLingui() 43 + const shadowedProfile = useProfileShadow(profile) 44 + const navigation = useNavigation<NavigationProp>() 45 + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { 46 + onMutate: () => { 47 + if (currentScreen === 'conversation') { 48 + navigation.dispatch(StackActions.pop()) 49 + } 50 + }, 51 + onError: () => { 52 + Toast.show(_('Failed to delete chat'), 'xmark') 53 + }, 54 + }) 55 + const [queueBlock] = useProfileBlockMutationQueue(shadowedProfile) 56 + 57 + const onPressDelete = useCallback(() => { 58 + Toast.show(_('Chat deleted'), 'check') 59 + leaveConvo() 60 + }, [leaveConvo, _]) 61 + 62 + const onPressBlock = useCallback(() => { 63 + Toast.show(_('Account blocked'), 'check') 64 + // block and also delete convo 65 + queueBlock() 66 + leaveConvo() 67 + }, [queueBlock, leaveConvo, _]) 68 + 69 + const reportControl = useDialogControl() 70 + 71 + const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage) 72 + ? convo.lastMessage 73 + : null 74 + 75 + return ( 76 + <> 77 + <Menu.Root> 78 + <Menu.Trigger label={_(msg`Reject chat request`)}> 79 + {({props: triggerProps}) => ( 80 + <Button 81 + {...triggerProps} 82 + {...props} 83 + label={triggerProps.accessibilityLabel} 84 + style={[a.flex_1]} 85 + color={color} 86 + variant={variant} 87 + size={size}> 88 + <ButtonText> 89 + {label || ( 90 + <Trans comment="Reject a chat request, this opens a menu with options"> 91 + Reject 92 + </Trans> 93 + )} 94 + </ButtonText> 95 + </Button> 96 + )} 97 + </Menu.Trigger> 98 + <Menu.Outer> 99 + <Menu.Group> 100 + {showDeleteConvo && ( 101 + <Menu.Item 102 + label={_(msg`Delete conversation`)} 103 + onPress={onPressDelete}> 104 + <Menu.ItemText> 105 + <Trans>Delete conversation</Trans> 106 + </Menu.ItemText> 107 + <Menu.ItemIcon icon={CircleX_Stroke2_Corner0_Rounded} /> 108 + </Menu.Item> 109 + )} 110 + <Menu.Item label={_(msg`Block account`)} onPress={onPressBlock}> 111 + <Menu.ItemText> 112 + <Trans>Block account</Trans> 113 + </Menu.ItemText> 114 + <Menu.ItemIcon icon={PersonXIcon} /> 115 + </Menu.Item> 116 + {/* note: last message will almost certainly be defined, since you can't 117 + delete messages for other people andit's impossible for a convo on this 118 + screen to have a message sent by you */} 119 + {lastMessage && ( 120 + <Menu.Item 121 + label={_(msg`Report conversation`)} 122 + onPress={reportControl.open}> 123 + <Menu.ItemText> 124 + <Trans>Report conversation</Trans> 125 + </Menu.ItemText> 126 + <Menu.ItemIcon icon={FlagIcon} /> 127 + </Menu.Item> 128 + )} 129 + </Menu.Group> 130 + </Menu.Outer> 131 + </Menu.Root> 132 + {lastMessage && ( 133 + <ReportDialog 134 + currentScreen={currentScreen} 135 + params={{ 136 + type: 'convoMessage', 137 + convoId: convo.id, 138 + message: lastMessage, 139 + }} 140 + control={reportControl} 141 + /> 142 + )} 143 + </> 144 + ) 145 + } 146 + 147 + export function AcceptChatButton({ 148 + convo, 149 + size = 'tiny', 150 + variant = 'solid', 151 + color = 'secondary_inverted', 152 + label, 153 + currentScreen, 154 + onAcceptConvo, 155 + ...props 156 + }: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & { 157 + label?: string 158 + convo: ChatBskyConvoDefs.ConvoView 159 + onAcceptConvo?: () => void 160 + currentScreen: 'list' | 'conversation' 161 + }) { 162 + const {_} = useLingui() 163 + const queryClient = useQueryClient() 164 + const navigation = useNavigation<NavigationProp>() 165 + 166 + const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, { 167 + onMutate: () => { 168 + onAcceptConvo?.() 169 + if (currentScreen === 'list') { 170 + precacheConvoQuery(queryClient, {...convo, status: 'accepted'}) 171 + navigation.navigate('MessagesConversation', { 172 + conversation: convo.id, 173 + accept: true, 174 + }) 175 + } 176 + }, 177 + onError: () => { 178 + // Should we show a toast here? They'll be on the convo screen, and it'll make 179 + // no difference if the request failed - when they send a message, the convo will be accepted 180 + // automatically. The only difference is that when they back out of the convo (without sending a message), the conversation will be rejected. 181 + // the list will still have this chat in it -sfn 182 + Toast.show(_('Failed to accept chat'), 'xmark') 183 + }, 184 + }) 185 + 186 + const onPressAccept = useCallback(() => { 187 + acceptConvo() 188 + }, [acceptConvo]) 189 + 190 + return ( 191 + <Button 192 + {...props} 193 + label={label || _(msg`Accept chat request`)} 194 + size={size} 195 + variant={variant} 196 + color={color} 197 + style={a.flex_1} 198 + onPress={onPressAccept}> 199 + {isPending ? ( 200 + <ButtonIcon icon={Loader} /> 201 + ) : ( 202 + <ButtonText> 203 + {label || <Trans comment="Accept a chat request">Accept</Trans>} 204 + </ButtonText> 205 + )} 206 + </Button> 207 + ) 208 + } 209 + 210 + export function DeleteChatButton({ 211 + convo, 212 + size = 'tiny', 213 + variant = 'outline', 214 + color = 'secondary', 215 + label, 216 + currentScreen, 217 + ...props 218 + }: Omit<ButtonProps, 'children' | 'label'> & { 219 + label?: string 220 + convo: ChatBskyConvoDefs.ConvoView 221 + currentScreen: 'list' | 'conversation' 222 + }) { 223 + const {_} = useLingui() 224 + const navigation = useNavigation<NavigationProp>() 225 + 226 + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { 227 + onMutate: () => { 228 + if (currentScreen === 'conversation') { 229 + navigation.dispatch(StackActions.pop()) 230 + } 231 + }, 232 + onError: () => { 233 + Toast.show(_('Failed to delete chat'), 'xmark') 234 + }, 235 + }) 236 + 237 + const onPressDelete = useCallback(() => { 238 + Toast.show(_('Chat deleted'), 'check') 239 + leaveConvo() 240 + }, [leaveConvo, _]) 241 + 242 + return ( 243 + <Button 244 + label={label || _(msg`Delete chat`)} 245 + size={size} 246 + variant={variant} 247 + color={color} 248 + style={a.flex_1} 249 + onPress={onPressDelete} 250 + {...props}> 251 + <ButtonText>{label || <Trans>Delete chat</Trans>}</ButtonText> 252 + </Button> 253 + ) 254 + }
+78
src/screens/Messages/components/RequestListItem.tsx
··· 1 + import {View} from 'react-native' 2 + import {ChatBskyConvoDefs} from '@atproto/api' 3 + import {Trans} from '@lingui/macro' 4 + 5 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 6 + import {useSession} from '#/state/session' 7 + import {atoms as a, tokens} from '#/alf' 8 + import {KnownFollowers} from '#/components/KnownFollowers' 9 + import {Text} from '#/components/Typography' 10 + import {ChatListItem} from './ChatListItem' 11 + import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' 12 + 13 + export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { 14 + const {currentAccount} = useSession() 15 + const moderationOpts = useModerationOpts() 16 + 17 + const otherUser = convo.members.find( 18 + member => member.did !== currentAccount?.did, 19 + ) 20 + 21 + if (!otherUser || !moderationOpts) { 22 + return null 23 + } 24 + 25 + const isDeletedAccount = otherUser.handle === 'missing.invalid' 26 + 27 + return ( 28 + <View style={[a.relative, a.flex_1]}> 29 + <ChatListItem convo={convo} showMenu={false}> 30 + <View style={[a.pt_xs, a.pb_2xs]}> 31 + <KnownFollowers 32 + profile={otherUser} 33 + moderationOpts={moderationOpts} 34 + minimal 35 + showIfEmpty 36 + /> 37 + </View> 38 + {/* spacer, since you can't nest pressables */} 39 + <View style={[a.pt_md, a.pb_xs, a.w_full, {opacity: 0}]} aria-hidden> 40 + {/* Placeholder text so that it responds to the font height */} 41 + <Text style={[a.text_xs, a.leading_tight, a.font_bold]}> 42 + <Trans comment="Accept a chat request">Accept Request</Trans> 43 + </Text> 44 + </View> 45 + </ChatListItem> 46 + <View 47 + style={[ 48 + a.absolute, 49 + a.pr_md, 50 + a.w_full, 51 + a.flex_row, 52 + a.align_center, 53 + a.gap_sm, 54 + { 55 + bottom: tokens.space.md, 56 + paddingLeft: tokens.space.lg + 52 + tokens.space.md, 57 + }, 58 + ]}> 59 + {!isDeletedAccount ? ( 60 + <> 61 + <AcceptChatButton convo={convo} currentScreen="list" /> 62 + <RejectMenu 63 + convo={convo} 64 + profile={otherUser} 65 + showDeleteConvo 66 + currentScreen="list" 67 + /> 68 + </> 69 + ) : ( 70 + <> 71 + <DeleteChatButton convo={convo} currentScreen="list" /> 72 + <View style={a.flex_1} /> 73 + </> 74 + )} 75 + </View> 76 + </View> 77 + ) 78 + }
+2 -2
src/screens/Settings/Settings.tsx
··· 28 28 import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' 29 29 import * as SettingsList from '#/screens/Settings/components/SettingsList' 30 30 import {atoms as a, tokens, useTheme} from '#/alf' 31 - import {AvatarStack} from '#/components/AvatarStack' 31 + import {AvatarStackWithFetch} from '#/components/AvatarStack' 32 32 import {useDialogControl} from '#/components/Dialog' 33 33 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 34 34 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' ··· 118 118 {showAccounts ? ( 119 119 <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" /> 120 120 ) : ( 121 - <AvatarStack 121 + <AvatarStackWithFetch 122 122 profiles={accounts 123 123 .map(acc => acc.did) 124 124 .filter(did => did !== currentAccount?.did)
+21
src/state/messages/convo/agent.ts
··· 105 105 this.ingestFirehose = this.ingestFirehose.bind(this) 106 106 this.onFirehoseConnect = this.onFirehoseConnect.bind(this) 107 107 this.onFirehoseError = this.onFirehoseError.bind(this) 108 + this.markConvoAccepted = this.markConvoAccepted.bind(this) 108 109 } 109 110 110 111 private commit() { ··· 145 146 deleteMessage: undefined, 146 147 sendMessage: undefined, 147 148 fetchMessageHistory: undefined, 149 + markConvoAccepted: undefined, 148 150 } 149 151 } 150 152 case ConvoStatus.Disabled: ··· 162 164 deleteMessage: this.deleteMessage, 163 165 sendMessage: this.sendMessage, 164 166 fetchMessageHistory: this.fetchMessageHistory, 167 + markConvoAccepted: this.markConvoAccepted, 165 168 } 166 169 } 167 170 case ConvoStatus.Error: { ··· 176 179 deleteMessage: undefined, 177 180 sendMessage: undefined, 178 181 fetchMessageHistory: undefined, 182 + markConvoAccepted: undefined, 179 183 } 180 184 } 181 185 default: { ··· 190 194 deleteMessage: undefined, 191 195 sendMessage: undefined, 192 196 fetchMessageHistory: undefined, 197 + markConvoAccepted: undefined, 193 198 } 194 199 } 195 200 } ··· 780 785 id: tempId, 781 786 message, 782 787 }) 788 + if (this.convo?.status === 'request') { 789 + this.convo = { 790 + ...this.convo, 791 + status: 'accepted', 792 + } 793 + } 783 794 this.commit() 784 795 785 796 if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { 786 797 this.processPendingMessages() 787 798 } 799 + } 800 + 801 + markConvoAccepted() { 802 + if (this.convo) { 803 + this.convo = { 804 + ...this.convo, 805 + status: 'accepted', 806 + } 807 + } 808 + this.commit() 788 809 } 789 810 790 811 async processPendingMessages() {
+2 -2
src/state/messages/convo/index.tsx
··· 19 19 RQKEY as getConvoKey, 20 20 useMarkAsReadMutation, 21 21 } from '#/state/queries/messages/conversation' 22 - import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-conversations' 22 + import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' 23 23 import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' 24 24 import {useAgent} from '#/state/session' 25 25 ··· 104 104 }) 105 105 } 106 106 queryClient.invalidateQueries({ 107 - queryKey: ListConvosQueryKey, 107 + queryKey: [ListConvosQueryKeyRoot], 108 108 }) 109 109 } 110 110 }
+8
src/state/messages/convo/types.ts
··· 141 141 message: ChatBskyConvoSendMessage.InputSchema['message'], 142 142 ) => void 143 143 type FetchMessageHistory = () => Promise<void> 144 + type MarkConvoAccepted = () => void 144 145 145 146 export type ConvoStateUninitialized = { 146 147 status: ConvoStatus.Uninitialized ··· 153 154 deleteMessage: undefined 154 155 sendMessage: undefined 155 156 fetchMessageHistory: undefined 157 + markConvoAccepted: undefined 156 158 } 157 159 export type ConvoStateInitializing = { 158 160 status: ConvoStatus.Initializing ··· 165 167 deleteMessage: undefined 166 168 sendMessage: undefined 167 169 fetchMessageHistory: undefined 170 + markConvoAccepted: undefined 168 171 } 169 172 export type ConvoStateReady = { 170 173 status: ConvoStatus.Ready ··· 177 180 deleteMessage: DeleteMessage 178 181 sendMessage: SendMessage 179 182 fetchMessageHistory: FetchMessageHistory 183 + markConvoAccepted: MarkConvoAccepted 180 184 } 181 185 export type ConvoStateBackgrounded = { 182 186 status: ConvoStatus.Backgrounded ··· 189 193 deleteMessage: DeleteMessage 190 194 sendMessage: SendMessage 191 195 fetchMessageHistory: FetchMessageHistory 196 + markConvoAccepted: MarkConvoAccepted 192 197 } 193 198 export type ConvoStateSuspended = { 194 199 status: ConvoStatus.Suspended ··· 201 206 deleteMessage: DeleteMessage 202 207 sendMessage: SendMessage 203 208 fetchMessageHistory: FetchMessageHistory 209 + markConvoAccepted: MarkConvoAccepted 204 210 } 205 211 export type ConvoStateError = { 206 212 status: ConvoStatus.Error ··· 213 219 deleteMessage: undefined 214 220 sendMessage: undefined 215 221 fetchMessageHistory: undefined 222 + markConvoAccepted: undefined 216 223 } 217 224 export type ConvoStateDisabled = { 218 225 status: ConvoStatus.Disabled ··· 225 232 deleteMessage: DeleteMessage 226 233 sendMessage: SendMessage 227 234 fetchMessageHistory: FetchMessageHistory 235 + markConvoAccepted: MarkConvoAccepted 228 236 } 229 237 export type ConvoState = 230 238 | ConvoStateUninitialized
+11 -7
src/state/messages/convo/util.ts
··· 8 8 } from './types' 9 9 10 10 /** 11 - * Checks if a `Convo` has a `status` that is "active", meaning the chat is 12 - * loaded and ready to be used, or its in a suspended or background state, and 13 - * ready for resumption. 11 + * States where the convo is ready to be used - either ready, or backgrounded/suspended 12 + * and ready to be resumed 14 13 */ 15 - export function isConvoActive( 16 - convo: ConvoState, 17 - ): convo is 14 + export type ActiveConvoStates = 18 15 | ConvoStateReady 19 16 | ConvoStateBackgrounded 20 17 | ConvoStateSuspended 21 - | ConvoStateDisabled { 18 + | ConvoStateDisabled 19 + 20 + /** 21 + * Checks if a `Convo` has a `status` that is "active", meaning the chat is 22 + * loaded and ready to be used, or its in a suspended or background state, and 23 + * ready for resumption. 24 + */ 25 + export function isConvoActive(convo: ConvoState): convo is ActiveConvoStates { 22 26 return ( 23 27 convo.status === ConvoStatus.Ready || 24 28 convo.status === ConvoStatus.Backgrounded ||
+135
src/state/queries/messages/accept-conversation.ts
··· 1 + import {ChatBskyConvoAcceptConvo, ChatBskyConvoListConvos} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {logger} from '#/logger' 5 + import {useAgent} from '#/state/session' 6 + import {DM_SERVICE_HEADERS} from './const' 7 + import { 8 + RQKEY as CONVO_LIST_KEY, 9 + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, 10 + } from './list-conversations' 11 + 12 + export function useAcceptConversation( 13 + convoId: string, 14 + { 15 + onSuccess, 16 + onMutate, 17 + onError, 18 + }: { 19 + onMutate?: () => void 20 + onSuccess?: (data: ChatBskyConvoAcceptConvo.OutputSchema) => void 21 + onError?: (error: Error) => void 22 + }, 23 + ) { 24 + const queryClient = useQueryClient() 25 + const agent = useAgent() 26 + 27 + return useMutation({ 28 + mutationFn: async () => { 29 + const {data} = await agent.chat.bsky.convo.acceptConvo( 30 + {convoId}, 31 + {headers: DM_SERVICE_HEADERS}, 32 + ) 33 + 34 + return data 35 + }, 36 + onMutate: () => { 37 + let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = [] 38 + let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = [] 39 + let convoBeingAccepted: 40 + | ChatBskyConvoListConvos.OutputSchema['convos'][number] 41 + | undefined 42 + queryClient.setQueryData( 43 + CONVO_LIST_KEY('request'), 44 + (old?: { 45 + pageParams: Array<string | undefined> 46 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 47 + }) => { 48 + if (!old) return old 49 + prevInboxPages = old.pages 50 + return { 51 + ...old, 52 + pages: old.pages.map(page => { 53 + const found = page.convos.find(convo => convo.id === convoId) 54 + if (found) { 55 + convoBeingAccepted = found 56 + return { 57 + ...page, 58 + convos: page.convos.filter(convo => convo.id !== convoId), 59 + } 60 + } 61 + return page 62 + }), 63 + } 64 + }, 65 + ) 66 + queryClient.setQueryData( 67 + CONVO_LIST_KEY('accepted'), 68 + (old?: { 69 + pageParams: Array<string | undefined> 70 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 71 + }) => { 72 + if (!old) return old 73 + prevAcceptedPages = old.pages 74 + if (convoBeingAccepted) { 75 + return { 76 + ...old, 77 + pages: [ 78 + { 79 + ...old.pages[0], 80 + convos: [ 81 + { 82 + ...convoBeingAccepted, 83 + status: 'accepted', 84 + }, 85 + ...old.pages[0].convos, 86 + ], 87 + }, 88 + ...old.pages.slice(1), 89 + ], 90 + } 91 + } else { 92 + return old 93 + } 94 + }, 95 + ) 96 + onMutate?.() 97 + return {prevAcceptedPages, prevInboxPages} 98 + }, 99 + onSuccess: data => { 100 + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 101 + onSuccess?.(data) 102 + }, 103 + onError: (error, _, context) => { 104 + logger.error(error) 105 + queryClient.setQueryData( 106 + CONVO_LIST_KEY('accepted'), 107 + (old?: { 108 + pageParams: Array<string | undefined> 109 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 110 + }) => { 111 + if (!old) return old 112 + return { 113 + ...old, 114 + pages: context?.prevAcceptedPages || old.pages, 115 + } 116 + }, 117 + ) 118 + queryClient.setQueryData( 119 + CONVO_LIST_KEY('request'), 120 + (old?: { 121 + pageParams: Array<string | undefined> 122 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 123 + }) => { 124 + if (!old) return old 125 + return { 126 + ...old, 127 + pages: context?.prevInboxPages || old.pages, 128 + } 129 + }, 130 + ) 131 + queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) 132 + onError?.(error) 133 + }, 134 + }) 135 + }
+28 -25
src/state/queries/messages/conversation.ts
··· 13 13 import { 14 14 ConvoListQueryData, 15 15 getConvoFromQueryData, 16 - RQKEY as LIST_CONVOS_KEY, 16 + RQKEY_ROOT as LIST_CONVOS_KEY, 17 17 } from './list-conversations' 18 18 19 19 const RQKEY_ROOT = 'convo' ··· 76 76 onSuccess(_, {convoId}) { 77 77 if (!convoId) return 78 78 79 - queryClient.setQueryData(LIST_CONVOS_KEY, (old: ConvoListQueryData) => { 80 - if (!old) return old 79 + queryClient.setQueriesData( 80 + {queryKey: [LIST_CONVOS_KEY]}, 81 + (old?: ConvoListQueryData) => { 82 + if (!old) return old 81 83 82 - const existingConvo = getConvoFromQueryData(convoId, old) 84 + const existingConvo = getConvoFromQueryData(convoId, old) 83 85 84 - if (existingConvo) { 85 - return { 86 - ...old, 87 - pages: old.pages.map(page => { 88 - return { 89 - ...page, 90 - convos: page.convos.map(convo => { 91 - if (convo.id === convoId) { 92 - return { 93 - ...convo, 94 - unreadCount: 0, 86 + if (existingConvo) { 87 + return { 88 + ...old, 89 + pages: old.pages.map(page => { 90 + return { 91 + ...page, 92 + convos: page.convos.map(convo => { 93 + if (convo.id === convoId) { 94 + return { 95 + ...convo, 96 + unreadCount: 0, 97 + } 95 98 } 96 - } 97 - return convo 98 - }), 99 - } 100 - }), 99 + return convo 100 + }), 101 + } 102 + }), 103 + } 104 + } else { 105 + // If we somehow marked a convo as read that doesn't exist in the 106 + // list, then we don't need to do anything. 101 107 } 102 - } else { 103 - // If we somehow marked a convo as read that doesn't exist in the 104 - // list, then we don't need to do anything. 105 - } 106 - }) 108 + }, 109 + ) 107 110 }, 108 111 }) 109 112 }
+25
src/state/queries/messages/get-convo-availability.ts
··· 1 + import {useQuery} from '@tanstack/react-query' 2 + 3 + import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 4 + import {useAgent} from '#/state/session' 5 + import {STALE} from '..' 6 + 7 + const RQKEY_ROOT = 'convo-availability' 8 + export const RQKEY = (did: string) => [RQKEY_ROOT, did] 9 + 10 + export function useGetConvoAvailabilityQuery(did: string) { 11 + const agent = useAgent() 12 + 13 + return useQuery({ 14 + queryKey: RQKEY(did), 15 + queryFn: async () => { 16 + const {data} = await agent.chat.bsky.convo.getConvoAvailability( 17 + {members: [did]}, 18 + {headers: DM_SERVICE_HEADERS}, 19 + ) 20 + 21 + return data 22 + }, 23 + staleTime: STALE.INFINITY, 24 + }) 25 + }
+4 -31
src/state/queries/messages/get-convo-for-members.ts
··· 1 1 import {ChatBskyConvoGetConvoForMembers} from '@atproto/api' 2 - import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 3 4 4 import {logger} from '#/logger' 5 5 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 6 6 import {useAgent} from '#/state/session' 7 - import {STALE} from '..' 8 - import {RQKEY as CONVO_KEY} from './conversation' 9 - 10 - const RQKEY_ROOT = 'convo-for-user' 11 - export const RQKEY = (did: string) => [RQKEY_ROOT, did] 7 + import {precacheConvoQuery} from './conversation' 12 8 13 9 export function useGetConvoForMembers({ 14 10 onSuccess, ··· 22 18 23 19 return useMutation({ 24 20 mutationFn: async (members: string[]) => { 25 - const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( 21 + const {data} = await agent.chat.bsky.convo.getConvoForMembers( 26 22 {members: members}, 27 23 {headers: DM_SERVICE_HEADERS}, 28 24 ) ··· 30 26 return data 31 27 }, 32 28 onSuccess: data => { 33 - queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) 29 + precacheConvoQuery(queryClient, data.convo) 34 30 onSuccess?.(data) 35 31 }, 36 32 onError: error => { ··· 39 35 }, 40 36 }) 41 37 } 42 - 43 - /** 44 - * Gets the conversation ID for a given DID. Returns null if it's not possible to message them. 45 - */ 46 - export function useMaybeConvoForUser(did: string) { 47 - const agent = useAgent() 48 - 49 - return useQuery({ 50 - queryKey: RQKEY(did), 51 - queryFn: async () => { 52 - const convo = await agent.api.chat.bsky.convo 53 - .getConvoForMembers({members: [did]}, {headers: DM_SERVICE_HEADERS}) 54 - .catch(() => ({success: null})) 55 - 56 - if (convo.success) { 57 - return convo.data.convo 58 - } else { 59 - return null 60 - } 61 - }, 62 - staleTime: STALE.INFINITY, 63 - }) 64 - }
+11 -7
src/state/queries/messages/leave-conversation.ts
··· 1 + import {useMemo} from 'react' 1 2 import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api' 2 3 import { 3 4 useMutation, ··· 8 9 import {logger} from '#/logger' 9 10 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 10 11 import {useAgent} from '#/state/session' 11 - import {RQKEY as CONVO_LIST_KEY} from './list-conversations' 12 + import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 12 13 13 14 const RQKEY_ROOT = 'leave-convo' 14 15 export function RQKEY(convoId: string | undefined) { ··· 35 36 mutationFn: async () => { 36 37 if (!convoId) throw new Error('No convoId provided') 37 38 38 - const {data} = await agent.api.chat.bsky.convo.leaveConvo( 39 + const {data} = await agent.chat.bsky.convo.leaveConvo( 39 40 {convoId}, 40 41 {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 41 42 ) ··· 45 46 onMutate: () => { 46 47 let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] 47 48 queryClient.setQueryData( 48 - CONVO_LIST_KEY, 49 + [CONVO_LIST_KEY], 49 50 (old?: { 50 51 pageParams: Array<string | undefined> 51 52 pages: Array<ChatBskyConvoListConvos.OutputSchema> ··· 67 68 return {prevPages} 68 69 }, 69 70 onSuccess: data => { 70 - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) 71 + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 71 72 onSuccess?.(data) 72 73 }, 73 74 onError: (error, _, context) => { 74 75 logger.error(error) 75 76 queryClient.setQueryData( 76 - CONVO_LIST_KEY, 77 + [CONVO_LIST_KEY], 77 78 (old?: { 78 79 pageParams: Array<string | undefined> 79 80 pages: Array<ChatBskyConvoListConvos.OutputSchema> ··· 85 86 } 86 87 }, 87 88 ) 88 - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) 89 + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 89 90 onError?.(error) 90 91 }, 91 92 }) ··· 105 106 filters: {mutationKey: [RQKEY_ROOT], status: 'success'}, 106 107 select: mutation => mutation.options.mutationKey?.[1] as string | undefined, 107 108 }) 108 - return [...pending, ...success].filter(id => id !== undefined) 109 + return useMemo( 110 + () => [...pending, ...success].filter(id => id !== undefined), 111 + [pending, success], 112 + ) 109 113 }
+294 -123
src/state/queries/messages/list-conversations.tsx
··· 9 9 ChatBskyConvoDefs, 10 10 ChatBskyConvoListConvos, 11 11 moderateProfile, 12 + ModerationOpts, 12 13 } from '@atproto/api' 13 14 import { 14 15 InfiniteData, ··· 23 24 import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 25 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 25 26 import {useAgent, useSession} from '#/state/session' 27 + import {useLeftConvos} from './leave-conversation' 26 28 27 - export const RQKEY = ['convo-list'] 29 + export const RQKEY_ROOT = 'convo-list' 30 + export const RQKEY = ( 31 + status: 'accepted' | 'request' | 'all', 32 + readState: 'all' | 'unread' = 'all', 33 + ) => [RQKEY_ROOT, status, readState] 28 34 type RQPageParam = string | undefined 29 35 30 36 export function useListConvosQuery({ 31 37 enabled, 38 + status, 39 + readState = 'all', 32 40 }: { 33 41 enabled?: boolean 42 + status?: 'request' | 'accepted' 43 + readState?: 'all' | 'unread' 34 44 } = {}) { 35 45 const agent = useAgent() 36 46 37 47 return useInfiniteQuery({ 38 48 enabled, 39 - queryKey: RQKEY, 49 + queryKey: RQKEY(status ?? 'all', readState), 40 50 queryFn: async ({pageParam}) => { 41 - const {data} = await agent.api.chat.bsky.convo.listConvos( 42 - {cursor: pageParam, limit: 20}, 51 + const {data} = await agent.chat.bsky.convo.listConvos( 52 + { 53 + limit: 20, 54 + cursor: pageParam, 55 + readState: readState === 'unread' ? 'unread' : undefined, 56 + status, 57 + }, 43 58 {headers: DM_SERVICE_HEADERS}, 44 59 ) 45 - 46 60 return data 47 61 }, 48 62 initialPageParam: undefined as RQPageParam, ··· 50 64 }) 51 65 } 52 66 53 - const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>( 54 - null, 55 - ) 67 + const ListConvosContext = createContext<{ 68 + accepted: ChatBskyConvoDefs.ConvoView[] 69 + request: ChatBskyConvoDefs.ConvoView[] 70 + } | null>(null) 56 71 57 72 export function useListConvos() { 58 73 const ctx = useContext(ListConvosContext) ··· 62 77 return ctx 63 78 } 64 79 80 + const empty = {accepted: [], request: []} 65 81 export function ListConvosProvider({children}: {children: React.ReactNode}) { 66 82 const {hasSession} = useSession() 67 83 68 84 if (!hasSession) { 69 85 return ( 70 - <ListConvosContext.Provider value={[]}> 86 + <ListConvosContext.Provider value={empty}> 71 87 {children} 72 88 </ListConvosContext.Provider> 73 89 ) ··· 81 97 }: { 82 98 children: React.ReactNode 83 99 }) { 84 - const {refetch, data} = useListConvosQuery() 100 + const {refetch, data} = useListConvosQuery({readState: 'unread'}) 85 101 const messagesBus = useMessagesEventBus() 86 102 const queryClient = useQueryClient() 87 103 const {currentConvoId} = useCurrentConvoId() 88 104 const {currentAccount} = useSession() 105 + const leftConvos = useLeftConvos() 89 106 90 - const debouncedRefetch = useMemo( 91 - () => 92 - throttle(refetch, 500, { 93 - leading: true, 94 - trailing: true, 95 - }), 96 - [refetch], 97 - ) 107 + const debouncedRefetch = useMemo(() => { 108 + const refetchAndInvalidate = () => { 109 + refetch() 110 + queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) 111 + } 112 + return throttle(refetchAndInvalidate, 500, { 113 + leading: true, 114 + trailing: true, 115 + }) 116 + }, [refetch, queryClient]) 98 117 99 118 useEffect(() => { 100 119 const unsub = messagesBus.on( ··· 105 124 if (ChatBskyConvoDefs.isLogBeginConvo(log)) { 106 125 debouncedRefetch() 107 126 } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { 108 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => 109 - optimisticDelete(log.convoId, old), 127 + queryClient.setQueriesData( 128 + {queryKey: [RQKEY_ROOT]}, 129 + (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), 110 130 ) 111 131 } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { 112 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => 113 - optimisticUpdate(log.convoId, old, convo => { 114 - if ( 115 - (ChatBskyConvoDefs.isDeletedMessageView(log.message) || 116 - ChatBskyConvoDefs.isMessageView(log.message)) && 117 - (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) || 118 - ChatBskyConvoDefs.isMessageView(convo.lastMessage)) 119 - ) { 120 - return log.message.id === convo.lastMessage.id 121 - ? { 122 - ...convo, 123 - rev: log.rev, 124 - lastMessage: log.message, 125 - } 126 - : convo 127 - } else { 128 - return convo 129 - } 130 - }), 132 + queryClient.setQueriesData( 133 + {queryKey: [RQKEY_ROOT]}, 134 + (old?: ConvoListQueryData) => 135 + optimisticUpdate(log.convoId, old, convo => { 136 + if ( 137 + (ChatBskyConvoDefs.isDeletedMessageView(log.message) || 138 + ChatBskyConvoDefs.isMessageView(log.message)) && 139 + (ChatBskyConvoDefs.isDeletedMessageView( 140 + convo.lastMessage, 141 + ) || 142 + ChatBskyConvoDefs.isMessageView(convo.lastMessage)) 143 + ) { 144 + return log.message.id === convo.lastMessage.id 145 + ? { 146 + ...convo, 147 + rev: log.rev, 148 + lastMessage: log.message, 149 + } 150 + : convo 151 + } else { 152 + return convo 153 + } 154 + }), 131 155 ) 132 156 } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) { 133 157 // Store in a new var to avoid TS errors due to closures. 134 158 const logRef: ChatBskyConvoDefs.LogCreateMessage = log 135 159 136 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 137 - if (!old) return old 160 + // Get all matching queries 161 + const queries = queryClient.getQueriesData<ConvoListQueryData>({ 162 + queryKey: [RQKEY_ROOT], 163 + }) 138 164 139 - function updateConvo(convo: ChatBskyConvoDefs.ConvoView) { 140 - let unreadCount = convo.unreadCount 141 - if (convo.id !== currentConvoId) { 142 - if ( 143 - ChatBskyConvoDefs.isMessageView(logRef.message) || 144 - ChatBskyConvoDefs.isDeletedMessageView(logRef.message) 145 - ) { 146 - if (logRef.message.sender.did !== currentAccount?.did) { 147 - unreadCount++ 165 + // Check if convo exists in any query 166 + let foundConvo: ChatBskyConvoDefs.ConvoView | null = null 167 + for (const [_key, query] of queries) { 168 + if (!query) continue 169 + const convo = getConvoFromQueryData(logRef.convoId, query) 170 + if (convo) { 171 + foundConvo = convo 172 + break 173 + } 174 + } 175 + 176 + if (!foundConvo) { 177 + // Convo not found, trigger refetch 178 + debouncedRefetch() 179 + return 180 + } 181 + 182 + // Update the convo 183 + const updatedConvo = { 184 + ...foundConvo, 185 + rev: logRef.rev, 186 + lastMessage: logRef.message, 187 + unreadCount: 188 + foundConvo.id !== currentConvoId 189 + ? (ChatBskyConvoDefs.isMessageView(logRef.message) || 190 + ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) && 191 + logRef.message.sender.did !== currentAccount?.did 192 + ? foundConvo.unreadCount + 1 193 + : foundConvo.unreadCount 194 + : 0, 195 + } 196 + 197 + function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) { 198 + return convo.filter(c => c.id !== logRef.convoId) 199 + } 200 + 201 + // Update all matching queries 202 + function updateFn(old?: ConvoListQueryData) { 203 + if (!old) return old 204 + return { 205 + ...old, 206 + pages: old.pages.map((page, i) => { 207 + if (i === 0) { 208 + return { 209 + ...page, 210 + convos: [ 211 + updatedConvo, 212 + ...filterConvoFromPage(page.convos), 213 + ], 148 214 } 149 215 } 150 - } else { 151 - unreadCount = 0 152 - } 153 - 154 - return { 216 + return { 217 + ...page, 218 + convos: filterConvoFromPage(page.convos), 219 + } 220 + }), 221 + } 222 + } 223 + // always update the unread one 224 + queryClient.setQueriesData( 225 + {queryKey: RQKEY('all', 'unread')}, 226 + (old?: ConvoListQueryData) => 227 + old 228 + ? updateFn(old) 229 + : ({ 230 + pageParams: [undefined], 231 + pages: [{convos: [updatedConvo], cursor: undefined}], 232 + } satisfies ConvoListQueryData), 233 + ) 234 + // update the other ones based on status of the incoming message 235 + if (updatedConvo.status === 'accepted') { 236 + queryClient.setQueriesData( 237 + {queryKey: RQKEY('accepted')}, 238 + updateFn, 239 + ) 240 + } else if (updatedConvo.status === 'request') { 241 + queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn) 242 + } 243 + } else if (ChatBskyConvoDefs.isLogReadMessage(log)) { 244 + const logRef: ChatBskyConvoDefs.LogReadMessage = log 245 + queryClient.setQueriesData( 246 + {queryKey: [RQKEY_ROOT]}, 247 + (old?: ConvoListQueryData) => 248 + optimisticUpdate(logRef.convoId, old, convo => ({ 155 249 ...convo, 250 + unreadCount: 0, 156 251 rev: logRef.rev, 157 - lastMessage: logRef.message, 158 - unreadCount, 252 + })), 253 + ) 254 + } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { 255 + const logRef: ChatBskyConvoDefs.LogAcceptConvo = log 256 + const requests = queryClient.getQueryData<ConvoListQueryData>( 257 + RQKEY('request'), 258 + ) 259 + if (!requests) { 260 + debouncedRefetch() 261 + return 262 + } 263 + const acceptedConvo = getConvoFromQueryData(log.convoId, requests) 264 + if (!acceptedConvo) { 265 + debouncedRefetch() 266 + return 267 + } 268 + queryClient.setQueryData( 269 + RQKEY('request'), 270 + (old?: ConvoListQueryData) => 271 + optimisticDelete(logRef.convoId, old), 272 + ) 273 + queryClient.setQueriesData( 274 + {queryKey: RQKEY('accepted')}, 275 + (old?: ConvoListQueryData) => { 276 + if (!old) { 277 + debouncedRefetch() 278 + return old 159 279 } 160 - } 161 - 162 - function filterConvoFromPage( 163 - convo: ChatBskyConvoDefs.ConvoView[], 164 - ) { 165 - return convo.filter(c => c.id !== logRef.convoId) 166 - } 167 - 168 - const existingConvo = getConvoFromQueryData(logRef.convoId, old) 169 - 170 - if (existingConvo) { 171 280 return { 172 281 ...old, 173 282 pages: old.pages.map((page, i) => { ··· 175 284 return { 176 285 ...page, 177 286 convos: [ 178 - updateConvo(existingConvo), 179 - ...filterConvoFromPage(page.convos), 287 + {...acceptedConvo, status: 'accepted'}, 288 + ...page.convos, 180 289 ], 181 290 } 182 291 } 183 - return { 184 - ...page, 185 - convos: filterConvoFromPage(page.convos), 186 - } 292 + return page 187 293 }), 188 294 } 189 - } else { 190 - /** 191 - * We received a message from an conversation old enough that 192 - * it doesn't exist in the query cache, meaning we need to 193 - * refetch and bump the old convo to the top. 194 - */ 195 - debouncedRefetch() 196 - } 197 - }) 295 + }, 296 + ) 297 + } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) { 298 + const logRef: ChatBskyConvoDefs.LogMuteConvo = log 299 + queryClient.setQueriesData( 300 + {queryKey: [RQKEY_ROOT]}, 301 + (old?: ConvoListQueryData) => 302 + optimisticUpdate(logRef.convoId, old, convo => ({ 303 + ...convo, 304 + muted: true, 305 + rev: logRef.rev, 306 + })), 307 + ) 308 + } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) { 309 + const logRef: ChatBskyConvoDefs.LogUnmuteConvo = log 310 + queryClient.setQueriesData( 311 + {queryKey: [RQKEY_ROOT]}, 312 + (old?: ConvoListQueryData) => 313 + optimisticUpdate(logRef.convoId, old, convo => ({ 314 + ...convo, 315 + muted: false, 316 + rev: logRef.rev, 317 + })), 318 + ) 198 319 } 199 320 } 200 321 }, ··· 208 329 }, [ 209 330 messagesBus, 210 331 currentConvoId, 211 - refetch, 212 332 queryClient, 213 333 currentAccount?.did, 214 334 debouncedRefetch, 215 335 ]) 216 336 217 337 const ctx = useMemo(() => { 218 - return data?.pages.flatMap(page => page.convos) ?? [] 219 - }, [data]) 338 + const convos = 339 + data?.pages 340 + .flatMap(page => page.convos) 341 + .filter(convo => !leftConvos.includes(convo.id)) ?? [] 342 + return { 343 + accepted: convos.filter(conv => conv.status === 'accepted'), 344 + request: convos.filter(conv => conv.status === 'request'), 345 + } 346 + }, [data, leftConvos]) 220 347 221 348 return ( 222 349 <ListConvosContext.Provider value={ctx}> ··· 228 355 export function useUnreadMessageCount() { 229 356 const {currentConvoId} = useCurrentConvoId() 230 357 const {currentAccount} = useSession() 231 - const convos = useListConvos() 358 + const {accepted, request} = useListConvos() 232 359 const moderationOpts = useModerationOpts() 233 360 234 - const count = useMemo(() => { 235 - return ( 236 - convos 237 - .filter(convo => convo.id !== currentConvoId) 238 - .reduce((acc, convo) => { 239 - const otherMember = convo.members.find( 240 - member => member.did !== currentAccount?.did, 241 - ) 361 + return useMemo<{ 362 + count: number 363 + numUnread?: string 364 + hasNew: boolean 365 + }>(() => { 366 + const acceptedCount = calculateCount( 367 + accepted, 368 + currentAccount?.did, 369 + currentConvoId, 370 + moderationOpts, 371 + ) 372 + const requestCount = calculateCount( 373 + request, 374 + currentAccount?.did, 375 + currentConvoId, 376 + moderationOpts, 377 + ) 378 + if (acceptedCount > 0) { 379 + const total = acceptedCount + Math.min(requestCount, 1) 380 + return { 381 + count: total, 382 + numUnread: total > 10 ? '10+' : String(total), 383 + // only needed when numUnread is undefined 384 + hasNew: false, 385 + } 386 + } else if (requestCount > 0) { 387 + return { 388 + count: 1, 389 + numUnread: undefined, 390 + hasNew: true, 391 + } 392 + } else { 393 + return { 394 + count: 0, 395 + numUnread: undefined, 396 + hasNew: false, 397 + } 398 + } 399 + }, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts]) 400 + } 242 401 243 - if (!otherMember || !moderationOpts) return acc 402 + function calculateCount( 403 + convos: ChatBskyConvoDefs.ConvoView[], 404 + currentAccountDid: string | undefined, 405 + currentConvoId: string | undefined, 406 + moderationOpts: ModerationOpts | undefined, 407 + ) { 408 + return ( 409 + convos 410 + .filter(convo => convo.id !== currentConvoId) 411 + .reduce((acc, convo) => { 412 + const otherMember = convo.members.find( 413 + member => member.did !== currentAccountDid, 414 + ) 244 415 245 - const moderation = moderateProfile(otherMember, moderationOpts) 246 - const shouldIgnore = 247 - convo.muted || 248 - moderation.blocked || 249 - otherMember.did === 'missing.invalid' 250 - const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 416 + if (!otherMember || !moderationOpts) return acc 251 417 252 - return acc + unreadCount 253 - }, 0) ?? 0 254 - ) 255 - }, [convos, currentAccount?.did, currentConvoId, moderationOpts]) 418 + const moderation = moderateProfile(otherMember, moderationOpts) 419 + const shouldIgnore = 420 + convo.muted || 421 + moderation.blocked || 422 + otherMember.handle === 'missing.invalid' 423 + const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 256 424 257 - return useMemo(() => { 258 - return { 259 - count, 260 - numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined, 261 - } 262 - }, [count]) 425 + return acc + unreadCount 426 + }, 0) ?? 0 427 + ) 263 428 } 264 429 265 430 export type ConvoListQueryData = { ··· 272 437 273 438 return useCallback( 274 439 (chatId: string) => { 275 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 276 - return optimisticUpdate(chatId, old, convo => ({ 277 - ...convo, 278 - unreadCount: 0, 279 - })) 280 - }) 440 + queryClient.setQueriesData( 441 + {queryKey: [RQKEY_ROOT]}, 442 + (old?: ConvoListQueryData) => { 443 + if (!old) return old 444 + return optimisticUpdate(chatId, old, convo => ({ 445 + ...convo, 446 + unreadCount: 0, 447 + })) 448 + }, 449 + ) 281 450 }, 282 451 [queryClient], 283 452 ) ··· 285 454 286 455 function optimisticUpdate( 287 456 chatId: string, 288 - old: ConvoListQueryData, 289 - updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, 457 + old?: ConvoListQueryData, 458 + updateFn?: ( 459 + convo: ChatBskyConvoDefs.ConvoView, 460 + ) => ChatBskyConvoDefs.ConvoView, 290 461 ) { 291 - if (!old) return old 462 + if (!old || !updateFn) return old 292 463 293 464 return { 294 465 ...old, ··· 301 472 } 302 473 } 303 474 304 - function optimisticDelete(chatId: string, old: ConvoListQueryData) { 475 + function optimisticDelete(chatId: string, old?: ConvoListQueryData) { 305 476 if (!old) return old 306 477 307 478 return { ··· 331 502 const queryDatas = queryClient.getQueriesData< 332 503 InfiniteData<ChatBskyConvoListConvos.OutputSchema> 333 504 >({ 334 - queryKey: RQKEY, 505 + queryKey: [RQKEY_ROOT], 335 506 }) 336 507 for (const [_queryKey, queryData] of queryDatas) { 337 508 if (!queryData?.pages) {
+2 -2
src/state/queries/messages/mute-conversation.ts
··· 8 8 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 9 9 import {useAgent} from '#/state/session' 10 10 import {RQKEY as CONVO_KEY} from './conversation' 11 - import {RQKEY as CONVO_LIST_KEY} from './list-conversations' 11 + import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 12 12 13 13 export function useMuteConvo( 14 14 convoId: string | undefined, ··· 53 53 ) 54 54 queryClient.setQueryData< 55 55 InfiniteData<ChatBskyConvoListConvos.OutputSchema> 56 - >(CONVO_LIST_KEY, prev => { 56 + >([CONVO_LIST_KEY], prev => { 57 57 if (!prev?.pages) return 58 58 return { 59 59 ...prev,
+105
src/state/queries/messages/update-all-read.ts
··· 1 + import {ChatBskyConvoListConvos} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {logger} from '#/logger' 5 + import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 6 + import {useAgent} from '#/state/session' 7 + import {RQKEY as CONVO_LIST_KEY} from './list-conversations' 8 + 9 + export function useUpdateAllRead( 10 + status: 'accepted' | 'request', 11 + { 12 + onSuccess, 13 + onMutate, 14 + onError, 15 + }: { 16 + onMutate?: () => void 17 + onSuccess?: () => void 18 + onError?: (error: Error) => void 19 + }, 20 + ) { 21 + const queryClient = useQueryClient() 22 + const agent = useAgent() 23 + 24 + return useMutation({ 25 + mutationFn: async () => { 26 + const {data} = await agent.chat.bsky.convo.updateAllRead( 27 + {status}, 28 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 29 + ) 30 + 31 + return data 32 + }, 33 + onMutate: () => { 34 + let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] 35 + queryClient.setQueryData( 36 + CONVO_LIST_KEY(status), 37 + (old?: { 38 + pageParams: Array<string | undefined> 39 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 40 + }) => { 41 + if (!old) return old 42 + prevPages = old.pages 43 + return { 44 + ...old, 45 + pages: old.pages.map(page => { 46 + return { 47 + ...page, 48 + convos: page.convos.map(convo => { 49 + return { 50 + ...convo, 51 + unreadCount: 0, 52 + } 53 + }), 54 + } 55 + }), 56 + } 57 + }, 58 + ) 59 + // remove unread convos from the badge query 60 + queryClient.setQueryData( 61 + CONVO_LIST_KEY('all', 'unread'), 62 + (old?: { 63 + pageParams: Array<string | undefined> 64 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 65 + }) => { 66 + if (!old) return old 67 + return { 68 + ...old, 69 + pages: old.pages.map(page => { 70 + return { 71 + ...page, 72 + convos: page.convos.filter(convo => convo.status !== status), 73 + } 74 + }), 75 + } 76 + }, 77 + ) 78 + onMutate?.() 79 + return {prevPages} 80 + }, 81 + onSuccess: () => { 82 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) 83 + onSuccess?.() 84 + }, 85 + onError: (error, _, context) => { 86 + logger.error(error) 87 + queryClient.setQueryData( 88 + CONVO_LIST_KEY(status), 89 + (old?: { 90 + pageParams: Array<string | undefined> 91 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 92 + }) => { 93 + if (!old) return old 94 + return { 95 + ...old, 96 + pages: context?.prevPages || old.pages, 97 + } 98 + }, 99 + ) 100 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) 101 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')}) 102 + onError?.(error) 103 + }, 104 + }) 105 + }
+2 -2
src/state/queries/profile.ts
··· 37 37 ProgressGuideAction, 38 38 useProgressGuideControls, 39 39 } from '../shell/progress-guide' 40 - import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-conversations' 40 + import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations' 41 41 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' 42 42 import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' 43 43 ··· 456 456 updateProfileShadow(queryClient, did, { 457 457 blockingUri: finalBlockingUri, 458 458 }) 459 - queryClient.invalidateQueries({queryKey: RQKEY_LIST_CONVOS}) 459 + queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) 460 460 }, 461 461 }) 462 462
+1 -7
src/view/com/util/UserAvatar.tsx
··· 264 264 onLoad={onLoad} 265 265 /> 266 266 )} 267 - <MediaInsetBorder 268 - style={[ 269 - { 270 - borderRadius: aviStyle.borderRadius, 271 - }, 272 - ]} 273 - /> 267 + <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} /> 274 268 {alert} 275 269 </View> 276 270 ) : (
+1
src/view/shell/bottom-bar/BottomBar.tsx
··· 199 199 } 200 200 onPress={onPressMessages} 201 201 notificationCount={numUnreadMessages.numUnread} 202 + hasNew={numUnreadMessages.hasNew} 202 203 accessible={true} 203 204 accessibilityRole="tab" 204 205 accessibilityLabel={_(msg`Chat`)}
+1 -2
src/view/shell/bottom-bar/BottomBarStyles.tsx
··· 14 14 paddingRight: 10, 15 15 }, 16 16 bottomBarWeb: { 17 - // @ts-ignore web-only 18 17 position: 'fixed', 19 18 }, 20 19 ctrl: { ··· 46 45 }, 47 46 hasNewBadge: { 48 47 position: 'absolute', 49 - left: '52%', 48 + left: '54%', 50 49 marginLeft: 4, 51 50 top: 10, 52 51 width: 8,
+2 -5
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 112 112 <NavItem 113 113 routeName="Messages" 114 114 href="/messages" 115 - notificationCount={ 116 - unreadMessageCount.count > 0 117 - ? unreadMessageCount.numUnread 118 - : undefined 119 - }> 115 + notificationCount={unreadMessageCount.numUnread} 116 + hasNew={unreadMessageCount.hasNew}> 120 117 {({isActive}) => { 121 118 const Icon = isActive ? MessageFilled : Message 122 119 return (
+5 -4
src/view/shell/desktop/LeftNav.tsx
··· 423 423 backgroundColor: t.palette.primary_500, 424 424 width: 8, 425 425 height: 8, 426 - right: -1, 427 - top: -3, 426 + right: -2, 427 + top: -4, 428 428 }, 429 429 leftNavMinimal && { 430 - right: 6, 431 - top: 4, 430 + right: 4, 431 + top: 2, 432 432 }, 433 433 ]} 434 434 /> ··· 520 520 <NavItem 521 521 href="/messages" 522 522 count={numUnreadMessages.numUnread} 523 + hasNew={numUnreadMessages.hasNew} 523 524 icon={ 524 525 <Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} /> 525 526 }