Bluesky app fork with some witchin' additions 💫

[Clipclops] 2 Clipped 2 Clopped (#3796)

* Add new pkg

* copy queries over to new file

* useConvoQuery

* useListConvos

* Use useListConvos

* extract useConvoQuery

* useGetConvoForMembers

* Delete unused

* exract useListConvos

* Replace imports

* Messages/List/index.tsx

* extract getconvoformembers

* MessageItem

* delete chatLog and rename query.ts

* Update import

* Clipclop service (#3794)

* Add Chat service

* Better handle deletions

* Rollback unneeded changes

* Better insertion order

* Use clipclops

* don't show FAB if error

* clean up imports

* Update Convo service

* Remove temp files

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
Samuel Newman
and committed by
GitHub
538ca8df d61b366b

+752 -1130
+1
package.json
··· 50 50 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 51 51 }, 52 52 "dependencies": { 53 + "@atproto-labs/api": "^0.12.8-clipclops.0", 53 54 "@atproto/api": "^0.12.5", 54 55 "@bam.tech/react-native-image-resizer": "^3.0.4", 55 56 "@braintree/sanitize-url": "^6.0.2",
+3 -3
src/components/dms/NewChat.tsx
··· 9 9 import {sanitizeHandle} from '#/lib/strings/handles' 10 10 import {isWeb} from '#/platform/detection' 11 11 import {useModerationOpts} from '#/state/preferences/moderation-opts' 12 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 12 13 import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' 13 14 import {FAB} from '#/view/com/util/fab/FAB' 14 15 import * as Toast from '#/view/com/util/Toast' ··· 17 18 import * as Dialog from '#/components/Dialog' 18 19 import * as TextField from '#/components/forms/TextField' 19 20 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 20 - import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query' 21 21 import {Button} from '../Button' 22 22 import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope' 23 23 import {ListMaybePlaceholder} from '../Lists' ··· 33 33 const t = useTheme() 34 34 const {_} = useLingui() 35 35 36 - const {mutate: createChat} = useGetChatFromMembers({ 36 + const {mutate: createChat} = useGetConvoForMembers({ 37 37 onSuccess: data => { 38 - onNewChat(data.chat.id) 38 + onNewChat(data.convo.id) 39 39 }, 40 40 onError: error => { 41 41 Toast.show(error.message)
+9 -6
src/screens/Messages/Conversation/MessageItem.tsx
··· 1 1 import React, {useCallback, useMemo} from 'react' 2 2 import {StyleProp, TextStyle, View} from 'react-native' 3 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 3 4 import {msg} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 ··· 7 8 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 8 9 import {atoms as a, useTheme} from '#/alf' 9 10 import {Text} from '#/components/Typography' 10 - import * as TempDmChatDefs from '#/temp/dm/defs' 11 11 12 12 export function MessageItem({ 13 13 item, 14 14 next, 15 15 }: { 16 - item: TempDmChatDefs.MessageView 17 - next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null 16 + item: ChatBskyConvoDefs.MessageView 17 + next: 18 + | ChatBskyConvoDefs.MessageView 19 + | ChatBskyConvoDefs.DeletedMessageView 20 + | null 18 21 }) { 19 22 const t = useTheme() 20 23 const {currentAccount} = useSession() ··· 22 25 const isFromSelf = item.sender?.did === currentAccount?.did 23 26 24 27 const isNextFromSelf = 25 - TempDmChatDefs.isMessageView(next) && 28 + ChatBskyConvoDefs.isMessageView(next) && 26 29 next.sender?.did === currentAccount?.did 27 30 28 31 const isLastInGroup = useMemo(() => { ··· 32 35 } 33 36 34 37 // or, if there's a 10 minute gap between this message and the next 35 - if (TempDmChatDefs.isMessageView(next)) { 38 + if (ChatBskyConvoDefs.isMessageView(next)) { 36 39 const thisDate = new Date(item.sentAt) 37 40 const nextDate = new Date(next.sentAt) 38 41 ··· 88 91 isLastInGroup, 89 92 style, 90 93 }: { 91 - message: TempDmChatDefs.MessageView 94 + message: ChatBskyConvoDefs.MessageView 92 95 isLastInGroup: boolean 93 96 style: StyleProp<TextStyle> 94 97 }) {
+61 -119
src/screens/Messages/Conversation/MessagesList.tsx
··· 1 - import React, {useCallback, useMemo, useRef, useState} from 'react' 1 + import React, {useCallback, useMemo, useRef} from 'react' 2 2 import {FlatList, View, ViewToken} from 'react-native' 3 - import {Alert} from 'react-native' 4 3 import {KeyboardAvoidingView} from 'react-native-keyboard-controller' 5 4 6 - import {isWeb} from '#/platform/detection' 5 + import {useChat} from '#/state/messages' 6 + import {ChatProvider} from '#/state/messages' 7 + import {ConvoItem, ConvoStatus} from '#/state/messages/convo' 8 + import {isWeb} from 'platform/detection' 7 9 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' 8 10 import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' 9 - import { 10 - useChat, 11 - useChatLogQuery, 12 - useSendMessageMutation, 13 - } from '#/screens/Messages/Temp/query/query' 14 11 import {Loader} from '#/components/Loader' 15 12 import {Text} from '#/components/Typography' 16 - import * as TempDmChatDefs from '#/temp/dm/defs' 17 - 18 - type MessageWithNext = { 19 - message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage 20 - next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null 21 - } 22 13 23 14 function MaybeLoader({isLoading}: {isLoading: boolean}) { 24 15 return ( ··· 34 25 ) 35 26 } 36 27 37 - function renderItem({item}: {item: MessageWithNext}) { 38 - if (TempDmChatDefs.isMessageView(item.message)) 39 - return <MessageItem item={item.message} next={item.next} /> 40 - 41 - if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text> 28 + function renderItem({item}: {item: ConvoItem}) { 29 + if (item.type === 'message') { 30 + return <MessageItem item={item.message} next={item.nextMessage} /> 31 + } else if (item.type === 'deleted-message') { 32 + return <Text>Deleted message</Text> 33 + } else if (item.type === 'pending-message') { 34 + return <Text>{item.message.text}</Text> 35 + } 42 36 43 37 return null 44 38 } 45 39 46 - // TODO rm 47 - // TEMP: This is a temporary function to generate unique keys for mutation placeholders 48 - const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}` 49 - 50 40 function onScrollToEndFailed() { 51 41 // Placeholder function. You have to give FlatList something or else it will error. 52 42 } 53 43 54 - export function MessagesList({chatId}: {chatId: string}) { 55 - const flatListRef = useRef<FlatList>(null) 56 - 57 - // Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true 58 - // once the request for new posts starts. Then, we will change it back to false after the content size changes. 59 - const isFetching = useRef(false) 44 + export function MessagesList({convoId}: {convoId: string}) { 45 + return ( 46 + <ChatProvider convoId={convoId}> 47 + <MessagesListInner /> 48 + </ChatProvider> 49 + ) 50 + } 60 51 52 + export function MessagesListInner() { 53 + const chat = useChat() 54 + const flatListRef = useRef<FlatList>(null) 61 55 // We use this to know if we should scroll after a new clop is added to the list 62 56 const isAtBottom = useRef(false) 63 57 64 58 // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the 65 59 // total number of clops 66 60 // TODO this needs to be set to whatever the initial number of messages is 67 - const totalMessages = useRef(10) 61 + // const totalMessages = useRef(10) 68 62 69 63 // TODO later 70 64 71 - const [_, setShowSpinner] = useState(false) 72 - 73 - // Query Data 74 - const {data: chat} = useChat(chatId) 75 - const {mutate: sendMessage} = useSendMessageMutation(chatId) 76 - useChatLogQuery() 77 - 78 65 const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => { 79 66 return [ 80 67 (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => { ··· 93 80 if (isAtBottom.current) { 94 81 flatListRef.current?.scrollToOffset({offset: 0, animated: true}) 95 82 } 96 - 97 - isFetching.current = false 98 - setShowSpinner(false) 99 83 }, []) 100 84 101 85 const onEndReached = useCallback(() => { 102 - if (isFetching.current) return 103 - isFetching.current = true 104 - setShowSpinner(true) 105 - 106 - // Eventually we will add more here when we hit the top through RQuery 107 - // We wouldn't actually use a timeout, but there would be a delay while loading 108 - setTimeout(() => { 109 - // Do something 110 - setShowSpinner(false) 111 - }, 1000) 112 - }, []) 86 + chat.service.fetchMessageHistory() 87 + }, [chat]) 113 88 114 89 const onInputFocus = useCallback(() => { 115 90 if (!isAtBottom.current) { ··· 117 92 } 118 93 }, []) 119 94 120 - const onSendMessage = useCallback( 121 - async (message: string) => { 122 - if (!message) return 123 - 124 - try { 125 - sendMessage({ 126 - message, 127 - tempId: generateUniqueKey(), 128 - }) 129 - } catch (e: any) { 130 - Alert.alert(e.toString()) 131 - } 132 - }, 133 - [sendMessage], 134 - ) 135 - 136 95 const onInputBlur = useCallback(() => {}, []) 137 96 138 - const messages = useMemo(() => { 139 - if (!chat) return [] 140 - 141 - const filtered = chat.messages 142 - .filter( 143 - ( 144 - message, 145 - ): message is 146 - | TempDmChatDefs.MessageView 147 - | TempDmChatDefs.DeletedMessage => { 148 - return ( 149 - TempDmChatDefs.isMessageView(message) || 150 - TempDmChatDefs.isDeletedMessage(message) 151 - ) 152 - }, 153 - ) 154 - .reduce((acc, message) => { 155 - // convert [n1, n2, n3, ...] to [{message: n1, next: n2}, {message: n2, next: n3}, {message: n3, next: n4}, ...] 156 - 157 - return [...acc, {message, next: acc.at(-1)?.message ?? null}] 158 - }, [] as MessageWithNext[]) 159 - totalMessages.current = filtered.length 160 - 161 - return filtered 162 - }, [chat]) 163 - 164 97 return ( 165 98 <KeyboardAvoidingView 166 99 style={{flex: 1, marginBottom: isWeb ? 20 : 85}} 167 100 behavior="padding" 168 101 keyboardVerticalOffset={70} 169 102 contentContainerStyle={{flex: 1}}> 170 - <FlatList 171 - data={messages} 172 - keyExtractor={item => item.message.id} 173 - renderItem={renderItem} 174 - contentContainerStyle={{paddingHorizontal: 10}} 175 - inverted={true} 176 - // In the future, we might want to adjust this value. Not very concerning right now as long as we are only 177 - // dealing with text. But whenever we have images or other media and things are taller, we will want to lower 178 - // this...probably. 179 - initialNumToRender={20} 180 - // Same with the max to render per batch. Let's be safe for now though. 181 - maxToRenderPerBatch={25} 182 - removeClippedSubviews={true} 183 - onEndReached={onEndReached} 184 - onScrollToIndexFailed={onScrollToEndFailed} 185 - onContentSizeChange={onContentSizeChange} 186 - onViewableItemsChanged={onViewableItemsChanged} 187 - viewabilityConfig={viewabilityConfig} 188 - maintainVisibleContentPosition={{ 189 - minIndexForVisible: 1, 190 - }} 191 - ListFooterComponent={<MaybeLoader isLoading={false} />} 192 - ref={flatListRef} 193 - keyboardDismissMode="none" 194 - /> 103 + {chat.state.status === ConvoStatus.Ready && ( 104 + <FlatList 105 + data={chat.state.items} 106 + keyExtractor={item => item.key} 107 + renderItem={renderItem} 108 + contentContainerStyle={{paddingHorizontal: 10}} 109 + // In the future, we might want to adjust this value. Not very concerning right now as long as we are only 110 + // dealing with text. But whenever we have images or other media and things are taller, we will want to lower 111 + // this...probably. 112 + initialNumToRender={20} 113 + // Same with the max to render per batch. Let's be safe for now though. 114 + maxToRenderPerBatch={25} 115 + inverted={true} 116 + onEndReached={onEndReached} 117 + onScrollToIndexFailed={onScrollToEndFailed} 118 + onContentSizeChange={onContentSizeChange} 119 + onViewableItemsChanged={onViewableItemsChanged} 120 + viewabilityConfig={viewabilityConfig} 121 + maintainVisibleContentPosition={{ 122 + minIndexForVisible: 0, 123 + }} 124 + ListFooterComponent={ 125 + <MaybeLoader isLoading={chat.state.isFetchingHistory} /> 126 + } 127 + removeClippedSubviews={true} 128 + ref={flatListRef} 129 + keyboardDismissMode="none" 130 + /> 131 + )} 132 + 195 133 <View style={{paddingHorizontal: 10}}> 196 134 <MessageInput 197 - onSendMessage={onSendMessage} 135 + onSendMessage={text => { 136 + chat.service.sendMessage({ 137 + text, 138 + }) 139 + }} 198 140 onFocus={onInputFocus} 199 141 onBlur={onInputBlur} 200 142 />
+4 -4
src/screens/Messages/Conversation/index.tsx
··· 9 9 10 10 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 11 11 import {useGate} from '#/lib/statsig/statsig' 12 + import {useConvoQuery} from '#/state/queries/messages/conversation' 12 13 import {BACK_HITSLOP} from 'lib/constants' 13 14 import {isWeb} from 'platform/detection' 14 15 import {useSession} from 'state/session' 15 16 import {UserAvatar} from 'view/com/util/UserAvatar' 16 17 import {CenteredView} from 'view/com/util/Views' 17 18 import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' 18 - import {useChatQuery} from '#/screens/Messages/Temp/query/query' 19 19 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 20 20 import {Button, ButtonIcon} from '#/components/Button' 21 21 import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid' ··· 29 29 > 30 30 export function MessagesConversationScreen({route}: Props) { 31 31 const gate = useGate() 32 - const chatId = route.params.conversation 32 + const convoId = route.params.conversation 33 33 const {currentAccount} = useSession() 34 34 const myDid = currentAccount?.did 35 35 36 - const {data: chat, isError: isError} = useChatQuery(chatId) 36 + const {data: chat, isError: isError} = useConvoQuery(convoId) 37 37 const otherProfile = React.useMemo(() => { 38 38 return chat?.members?.find(m => m.did !== myDid) 39 39 }, [chat?.members, myDid]) ··· 51 51 return ( 52 52 <CenteredView style={{flex: 1}} sideBorders> 53 53 <Header profile={otherProfile} /> 54 - <MessagesList chatId={chatId} /> 54 + <MessagesList convoId={convoId} /> 55 55 </CenteredView> 56 56 ) 57 57 }
+26 -23
src/screens/Messages/List/index.tsx
··· 2 2 3 3 import React, {useCallback, useMemo, useState} from 'react' 4 4 import {View} from 'react-native' 5 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' 7 8 import {NativeStackScreenProps} from '@react-navigation/native-stack' ··· 11 12 import {useGate} from '#/lib/statsig/statsig' 12 13 import {cleanError} from '#/lib/strings/errors' 13 14 import {logger} from '#/logger' 15 + import {useListConvos} from '#/state/queries/messages/list-converations' 14 16 import {useSession} from '#/state/session' 15 17 import {List} from '#/view/com/util/List' 16 18 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 17 19 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 18 20 import {ViewHeader} from '#/view/com/util/ViewHeader' 19 - import {useBreakpoints, useTheme} from '#/alf' 20 - import {atoms as a} from '#/alf' 21 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 21 22 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 23 import {DialogControlProps, useDialogControl} from '#/components/Dialog' 24 + import {NewChat} from '#/components/dms/NewChat' 23 25 import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 24 26 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 25 27 import {Link} from '#/components/Link' 26 28 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 27 29 import {Text} from '#/components/Typography' 28 - import * as TempDmChatDefs from '#/temp/dm/defs' 29 - import {NewChat} from '../../../components/dms/NewChat' 30 30 import {ClipClopGate} from '../gate' 31 - import {useListChats} from '../Temp/query/query' 32 31 33 32 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> 34 33 export function MessagesListScreen({navigation}: Props) { ··· 59 58 fetchNextPage, 60 59 error, 61 60 refetch, 62 - } = useListChats() 61 + } = useListConvos() 63 62 64 63 const isError = !!error 65 64 66 65 const conversations = useMemo(() => { 67 66 if (data?.pages) { 68 - return data.pages.flatMap(page => page.chats) 67 + return data.pages.flatMap(page => page.convos) 69 68 } 70 69 return [] 71 70 }, [data]) ··· 99 98 navigation.navigate('MessagesSettings') 100 99 }, [navigation]) 101 100 102 - const renderItem = useCallback(({item}: {item: TempDmChatDefs.ChatView}) => { 103 - return <ChatListItem key={item.id} chat={item} /> 104 - }, []) 101 + const renderItem = useCallback( 102 + ({item}: {item: ChatBskyConvoDefs.ConvoView}) => { 103 + return <ChatListItem key={item.id} convo={item} /> 104 + }, 105 + [], 106 + ) 105 107 106 108 const gate = useGate() 107 109 if (!gate('dms')) return <ClipClopGate /> ··· 119 121 errorMessage={cleanError(error)} 120 122 onRetry={isError ? refetch : undefined} 121 123 /> 122 - <NewChat onNewChat={onNewChat} control={newChatControl} /> 124 + {!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />} 123 125 </> 124 126 ) 125 127 } ··· 166 168 ) 167 169 } 168 170 169 - function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) { 171 + function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { 170 172 const t = useTheme() 171 173 const {_} = useLingui() 172 174 const {currentAccount} = useSession() 173 175 174 176 let lastMessage = _(msg`No messages yet`) 175 177 let lastMessageSentAt: string | null = null 176 - if (TempDmChatDefs.isMessageView(chat.lastMessage)) { 177 - if (chat.lastMessage.sender?.did === currentAccount?.did) { 178 - lastMessage = _(msg`You: ${chat.lastMessage.text}`) 178 + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { 179 + if (convo.lastMessage.sender?.did === currentAccount?.did) { 180 + lastMessage = _(msg`You: ${convo.lastMessage.text}`) 179 181 } else { 180 - lastMessage = chat.lastMessage.text 182 + lastMessage = convo.lastMessage.text 181 183 } 182 - lastMessageSentAt = chat.lastMessage.sentAt 184 + lastMessageSentAt = convo.lastMessage.sentAt 183 185 } 184 - if (TempDmChatDefs.isDeletedMessage(chat.lastMessage)) { 186 + if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { 185 187 lastMessage = _(msg`Message deleted`) 186 188 } 187 189 188 - const otherUser = chat.members.find( 190 + const otherUser = convo.members.find( 189 191 member => member.did !== currentAccount?.did, 190 192 ) 191 193 ··· 194 196 } 195 197 196 198 return ( 197 - <Link to={`/messages/${chat.id}`} style={a.flex_1}> 199 + <Link to={`/messages/${convo.id}`} style={a.flex_1}> 198 200 {({hovered, pressed}) => ( 199 201 <View 200 202 style={[ ··· 211 213 </View> 212 214 <View style={[a.flex_1]}> 213 215 <Text numberOfLines={1} style={[a.text_md, a.leading_normal]}> 214 - <Text style={[t.atoms.text, chat.unreadCount > 0 && a.font_bold]}> 216 + <Text 217 + style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}> 215 218 {otherUser.displayName || otherUser.handle} 216 219 </Text>{' '} 217 220 {lastMessageSentAt ? ( ··· 233 236 style={[ 234 237 a.text_sm, 235 238 a.leading_snug, 236 - chat.unreadCount > 0 239 + convo.unreadCount > 0 237 240 ? a.font_bold 238 241 : t.atoms.text_contrast_medium, 239 242 ]}> 240 243 {lastMessage} 241 244 </Text> 242 245 </View> 243 - {chat.unreadCount > 0 && ( 246 + {convo.unreadCount > 0 && ( 244 247 <View 245 248 style={[ 246 249 a.flex_0,
-305
src/screens/Messages/Temp/query/query.ts
··· 1 - import { 2 - useInfiniteQuery, 3 - useMutation, 4 - useQuery, 5 - useQueryClient, 6 - } from '@tanstack/react-query' 7 - 8 - import {useSession} from '#/state/session' 9 - import * as TempDmChatDefs from '#/temp/dm/defs' 10 - import * as TempDmChatGetChat from '#/temp/dm/getChat' 11 - import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers' 12 - import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' 13 - import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' 14 - import * as TempDmChatListChats from '#/temp/dm/listChats' 15 - import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage' 16 - 17 - /** 18 - * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏 19 - * (and do not try this at home) 20 - */ 21 - 22 - const useHeaders = () => { 23 - const {currentAccount} = useSession() 24 - return { 25 - get Authorization() { 26 - return currentAccount!.did 27 - }, 28 - } 29 - } 30 - 31 - type Chat = { 32 - chatId: string 33 - messages: TempDmChatGetChatMessages.OutputSchema['messages'] 34 - lastCursor?: string 35 - lastRev?: string 36 - } 37 - 38 - export function useChat(chatId: string) { 39 - const queryClient = useQueryClient() 40 - const headers = useHeaders() 41 - const {serviceUrl} = useDmServiceUrlStorage() 42 - 43 - return useQuery({ 44 - queryKey: ['chat', chatId], 45 - queryFn: async () => { 46 - const currentChat = queryClient.getQueryData(['chat', chatId]) 47 - 48 - if (currentChat) { 49 - return currentChat as Chat 50 - } 51 - 52 - const messagesResponse = await fetch( 53 - `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`, 54 - { 55 - headers, 56 - }, 57 - ) 58 - 59 - if (!messagesResponse.ok) throw new Error('Failed to fetch messages') 60 - 61 - const messagesJson = 62 - (await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema 63 - 64 - const chatResponse = await fetch( 65 - `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`, 66 - { 67 - headers, 68 - }, 69 - ) 70 - 71 - if (!chatResponse.ok) throw new Error('Failed to fetch chat') 72 - 73 - const chatJson = 74 - (await chatResponse.json()) as TempDmChatGetChat.OutputSchema 75 - 76 - queryClient.setQueryData(['chatQuery', chatId], chatJson.chat) 77 - 78 - const newChat = { 79 - chatId, 80 - messages: messagesJson.messages, 81 - lastCursor: messagesJson.cursor, 82 - lastRev: chatJson.chat.rev, 83 - } satisfies Chat 84 - 85 - queryClient.setQueryData(['chat', chatId], newChat) 86 - 87 - return newChat 88 - }, 89 - }) 90 - } 91 - 92 - interface SendMessageMutationVariables { 93 - message: string 94 - tempId: string 95 - } 96 - 97 - export function createTempId() { 98 - return Math.random().toString(36).substring(7).toString() 99 - } 100 - 101 - export function useSendMessageMutation(chatId: string) { 102 - const queryClient = useQueryClient() 103 - const headers = useHeaders() 104 - const {serviceUrl} = useDmServiceUrlStorage() 105 - 106 - return useMutation< 107 - TempDmChatDefs.Message, 108 - Error, 109 - SendMessageMutationVariables, 110 - unknown 111 - >({ 112 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 113 - mutationFn: async ({message, tempId}) => { 114 - const response = await fetch( 115 - `${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`, 116 - { 117 - method: 'POST', 118 - headers: { 119 - ...headers, 120 - 'Content-Type': 'application/json', 121 - }, 122 - body: JSON.stringify({ 123 - chatId, 124 - message: { 125 - text: message, 126 - }, 127 - }), 128 - }, 129 - ) 130 - 131 - if (!response.ok) throw new Error('Failed to send message') 132 - 133 - return response.json() 134 - }, 135 - onMutate: async variables => { 136 - queryClient.setQueryData(['chat', chatId], (prev: Chat) => { 137 - return { 138 - ...prev, 139 - messages: [ 140 - { 141 - $type: 'temp.dm.defs#messageView', 142 - id: variables.tempId, 143 - text: variables.message, 144 - sender: {did: headers.Authorization}, // TODO a real DID get 145 - sentAt: new Date().toISOString(), 146 - }, 147 - ...prev.messages, 148 - ], 149 - } 150 - }) 151 - }, 152 - onSuccess: (result, variables) => { 153 - queryClient.setQueryData(['chat', chatId], (prev: Chat) => { 154 - return { 155 - ...prev, 156 - messages: prev.messages.map(m => 157 - m.id === variables.tempId ? {...m, id: result.id} : m, 158 - ), 159 - } 160 - }) 161 - }, 162 - onError: (_, variables) => { 163 - console.log(_) 164 - queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({ 165 - ...prev, 166 - messages: prev.messages.filter(m => m.id !== variables.tempId), 167 - })) 168 - }, 169 - }) 170 - } 171 - 172 - export function useChatLogQuery() { 173 - const queryClient = useQueryClient() 174 - const headers = useHeaders() 175 - const {serviceUrl} = useDmServiceUrlStorage() 176 - 177 - return useQuery({ 178 - queryKey: ['chatLog'], 179 - queryFn: async () => { 180 - const prevLog = queryClient.getQueryData([ 181 - 'chatLog', 182 - ]) as TempDmChatGetChatLog.OutputSchema 183 - 184 - try { 185 - const response = await fetch( 186 - `${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${ 187 - prevLog?.cursor ?? '' 188 - }`, 189 - { 190 - headers, 191 - }, 192 - ) 193 - 194 - if (!response.ok) throw new Error('Failed to fetch chat log') 195 - 196 - const json = 197 - (await response.json()) as TempDmChatGetChatLog.OutputSchema 198 - 199 - if (json.logs.length > 0) { 200 - queryClient.invalidateQueries({queryKey: ['chats']}) 201 - } 202 - 203 - for (const log of json.logs) { 204 - if (TempDmChatDefs.isLogCreateMessage(log)) { 205 - queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => { 206 - // TODO hack filter out duplicates 207 - if (prev?.messages.find(m => m.id === log.message.id)) return 208 - 209 - return { 210 - ...prev, 211 - messages: [log.message, ...prev.messages], 212 - } 213 - }) 214 - } 215 - } 216 - 217 - return json 218 - } catch (e) { 219 - console.log(e) 220 - } 221 - }, 222 - refetchInterval: 5000, 223 - }) 224 - } 225 - 226 - export function useGetChatFromMembers({ 227 - onSuccess, 228 - onError, 229 - }: { 230 - onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void 231 - onError?: (error: Error) => void 232 - }) { 233 - const queryClient = useQueryClient() 234 - const headers = useHeaders() 235 - const {serviceUrl} = useDmServiceUrlStorage() 236 - 237 - return useMutation({ 238 - mutationFn: async (members: string[]) => { 239 - const response = await fetch( 240 - `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join( 241 - ',', 242 - )}`, 243 - {headers}, 244 - ) 245 - 246 - if (!response.ok) throw new Error('Failed to fetch chat') 247 - 248 - return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema 249 - }, 250 - onSuccess: data => { 251 - queryClient.setQueryData(['chat', data.chat.id], { 252 - chatId: data.chat.id, 253 - messages: [], 254 - lastRev: data.chat.rev, 255 - }) 256 - onSuccess?.(data) 257 - }, 258 - onError, 259 - }) 260 - } 261 - 262 - export function useListChats() { 263 - const headers = useHeaders() 264 - const {serviceUrl} = useDmServiceUrlStorage() 265 - 266 - return useInfiniteQuery({ 267 - queryKey: ['chats'], 268 - queryFn: async ({pageParam}) => { 269 - const response = await fetch( 270 - `${serviceUrl}/xrpc/temp.dm.listChats${ 271 - pageParam ? `?cursor=${pageParam}` : '' 272 - }`, 273 - {headers}, 274 - ) 275 - 276 - if (!response.ok) throw new Error('Failed to fetch chats') 277 - 278 - return (await response.json()) as TempDmChatListChats.OutputSchema 279 - }, 280 - initialPageParam: undefined as string | undefined, 281 - getNextPageParam: lastPage => lastPage.cursor, 282 - }) 283 - } 284 - 285 - export function useChatQuery(chatId: string) { 286 - const headers = useHeaders() 287 - const {serviceUrl} = useDmServiceUrlStorage() 288 - 289 - return useQuery({ 290 - queryKey: ['chatQuery', chatId], 291 - queryFn: async () => { 292 - const chatResponse = await fetch( 293 - `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`, 294 - { 295 - headers, 296 - }, 297 - ) 298 - 299 - if (!chatResponse.ok) throw new Error('Failed to fetch chat') 300 - 301 - const json = (await chatResponse.json()) as TempDmChatGetChat.OutputSchema 302 - return json.chat 303 - }, 304 - }) 305 - }
+38
src/state/messages/__tests__/client.test.ts
··· 1 + import {describe, it} from '@jest/globals' 2 + 3 + describe(`#/state/dms/client`, () => { 4 + describe(`ChatsService`, () => { 5 + describe(`unread count`, () => { 6 + it.todo(`marks a chat as read, decrements total unread count`) 7 + }) 8 + 9 + describe(`log processing`, () => { 10 + /* 11 + * We receive a new chat log AND messages for it in the same batch. We 12 + * need to first initialize the chat, then process the received logs. 13 + */ 14 + describe(`handles new chats and subsequent messages received in same log batch`, () => { 15 + it.todo(`receives new chat and messages`) 16 + it.todo( 17 + `receives new chat, new messages come in while still initializing new chat`, 18 + ) 19 + }) 20 + }) 21 + 22 + describe(`reset state`, () => { 23 + it.todo(`after period of inactivity, rehydrates entirely fresh state`) 24 + }) 25 + }) 26 + 27 + describe(`ChatService`, () => { 28 + describe(`history fetching`, () => { 29 + it.todo(`fetches initial chat history`) 30 + it.todo(`fetches additional chat history`) 31 + it.todo(`handles history fetch failure`) 32 + }) 33 + 34 + describe(`optimistic updates`, () => { 35 + it.todo(`adds sending messages`) 36 + }) 37 + }) 38 + })
+442
src/state/messages/convo.ts
··· 1 + import { 2 + BskyAgent, 3 + ChatBskyConvoDefs, 4 + ChatBskyConvoSendMessage, 5 + } from '@atproto-labs/api' 6 + import {EventEmitter} from 'eventemitter3' 7 + import {nanoid} from 'nanoid/non-secure' 8 + 9 + export type ConvoParams = { 10 + convoId: string 11 + agent: BskyAgent 12 + __tempFromUserDid: string 13 + } 14 + 15 + export enum ConvoStatus { 16 + Uninitialized = 'uninitialized', 17 + Initializing = 'initializing', 18 + Ready = 'ready', 19 + Error = 'error', 20 + Destroyed = 'destroyed', 21 + } 22 + 23 + export type ConvoItem = 24 + | { 25 + type: 'message' 26 + key: string 27 + message: ChatBskyConvoDefs.MessageView 28 + nextMessage: 29 + | ChatBskyConvoDefs.MessageView 30 + | ChatBskyConvoDefs.DeletedMessageView 31 + | null 32 + } 33 + | { 34 + type: 'deleted-message' 35 + key: string 36 + message: ChatBskyConvoDefs.DeletedMessageView 37 + nextMessage: 38 + | ChatBskyConvoDefs.MessageView 39 + | ChatBskyConvoDefs.DeletedMessageView 40 + | null 41 + } 42 + | { 43 + type: 'pending-message' 44 + key: string 45 + message: ChatBskyConvoSendMessage.InputSchema['message'] 46 + } 47 + 48 + export type ConvoState = 49 + | { 50 + status: ConvoStatus.Uninitialized 51 + } 52 + | { 53 + status: ConvoStatus.Initializing 54 + } 55 + | { 56 + status: ConvoStatus.Ready 57 + items: ConvoItem[] 58 + convo: ChatBskyConvoDefs.ConvoView 59 + isFetchingHistory: boolean 60 + } 61 + | { 62 + status: ConvoStatus.Error 63 + error: any 64 + } 65 + | { 66 + status: ConvoStatus.Destroyed 67 + } 68 + 69 + export class Convo { 70 + private convoId: string 71 + private agent: BskyAgent 72 + private __tempFromUserDid: string 73 + 74 + private status: ConvoStatus = ConvoStatus.Uninitialized 75 + private error: any 76 + private convo: ChatBskyConvoDefs.ConvoView | undefined 77 + private historyCursor: string | undefined | null = undefined 78 + private isFetchingHistory = false 79 + private eventsCursor: string | undefined = undefined 80 + 81 + private pastMessages: Map< 82 + string, 83 + ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView 84 + > = new Map() 85 + private newMessages: Map< 86 + string, 87 + ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView 88 + > = new Map() 89 + private pendingMessages: Map< 90 + string, 91 + {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} 92 + > = new Map() 93 + 94 + private pendingEventIngestion: Promise<void> | undefined 95 + 96 + constructor(params: ConvoParams) { 97 + this.convoId = params.convoId 98 + this.agent = params.agent 99 + this.__tempFromUserDid = params.__tempFromUserDid 100 + } 101 + 102 + async initialize() { 103 + if (this.status !== 'uninitialized') return 104 + this.status = ConvoStatus.Initializing 105 + 106 + try { 107 + const response = await this.agent.api.chat.bsky.convo.getConvo( 108 + { 109 + convoId: this.convoId, 110 + }, 111 + { 112 + headers: { 113 + Authorization: this.__tempFromUserDid, 114 + }, 115 + }, 116 + ) 117 + const {convo} = response.data 118 + 119 + this.convo = convo 120 + this.status = ConvoStatus.Ready 121 + 122 + this.commit() 123 + 124 + await this.fetchMessageHistory() 125 + 126 + this.pollEvents() 127 + } catch (e) { 128 + this.status = ConvoStatus.Error 129 + this.error = e 130 + } 131 + } 132 + 133 + private async pollEvents() { 134 + if (this.status === ConvoStatus.Destroyed) return 135 + if (this.pendingEventIngestion) return 136 + setTimeout(async () => { 137 + this.pendingEventIngestion = this.ingestLatestEvents() 138 + await this.pendingEventIngestion 139 + this.pendingEventIngestion = undefined 140 + this.pollEvents() 141 + }, 5e3) 142 + } 143 + 144 + async fetchMessageHistory() { 145 + if (this.status === ConvoStatus.Destroyed) return 146 + // reached end 147 + if (this.historyCursor === null) return 148 + if (this.isFetchingHistory) return 149 + 150 + this.isFetchingHistory = true 151 + this.commit() 152 + 153 + /* 154 + * Delay if paginating while scrolled. 155 + * 156 + * TODO why does the FlatList jump without this delay? 157 + * 158 + * Tbh it feels a little more natural with a slight delay. 159 + */ 160 + if (this.pastMessages.size > 0) { 161 + await new Promise(y => setTimeout(y, 500)) 162 + } 163 + 164 + const response = await this.agent.api.chat.bsky.convo.getMessages( 165 + { 166 + cursor: this.historyCursor, 167 + convoId: this.convoId, 168 + limit: 20, 169 + }, 170 + { 171 + headers: { 172 + Authorization: this.__tempFromUserDid, 173 + }, 174 + }, 175 + ) 176 + const {cursor, messages} = response.data 177 + 178 + this.historyCursor = cursor || null 179 + 180 + for (const message of messages) { 181 + if ( 182 + ChatBskyConvoDefs.isMessageView(message) || 183 + ChatBskyConvoDefs.isDeletedMessageView(message) 184 + ) { 185 + this.pastMessages.set(message.id, message) 186 + 187 + // set to latest rev 188 + if ( 189 + message.rev > (this.eventsCursor = this.eventsCursor || message.rev) 190 + ) { 191 + this.eventsCursor = message.rev 192 + } 193 + } 194 + } 195 + 196 + this.isFetchingHistory = false 197 + this.commit() 198 + } 199 + 200 + async ingestLatestEvents() { 201 + if (this.status === ConvoStatus.Destroyed) return 202 + 203 + const response = await this.agent.api.chat.bsky.convo.getLog( 204 + { 205 + cursor: this.eventsCursor, 206 + }, 207 + { 208 + headers: { 209 + Authorization: this.__tempFromUserDid, 210 + }, 211 + }, 212 + ) 213 + const {logs} = response.data 214 + 215 + for (const log of logs) { 216 + /* 217 + * If there's a rev, we should handle it. If there's not a rev, we don't 218 + * know what it is. 219 + */ 220 + if (typeof log.rev === 'string') { 221 + /* 222 + * We only care about new events 223 + */ 224 + if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { 225 + /* 226 + * Update rev regardless of if it's a log type we care about or not 227 + */ 228 + this.eventsCursor = log.rev 229 + 230 + /* 231 + * This is VERY important. We don't want to insert any messages from 232 + * your other chats. 233 + * 234 + * TODO there may be a better way to handle this 235 + */ 236 + if (log.convoId !== this.convoId) continue 237 + 238 + if ( 239 + ChatBskyConvoDefs.isLogCreateMessage(log) && 240 + ChatBskyConvoDefs.isMessageView(log.message) 241 + ) { 242 + if (this.newMessages.has(log.message.id)) { 243 + // Trust the log as the source of truth on ordering 244 + // TODO test this 245 + this.newMessages.delete(log.message.id) 246 + } 247 + this.newMessages.set(log.message.id, log.message) 248 + } else if ( 249 + ChatBskyConvoDefs.isLogDeleteMessage(log) && 250 + ChatBskyConvoDefs.isDeletedMessageView(log.message) 251 + ) { 252 + /* 253 + * Update if we have this in state. If we don't, don't worry about it. 254 + */ 255 + if (this.pastMessages.has(log.message.id)) { 256 + /* 257 + * For now, we remove deleted messages from the thread, if we receive one. 258 + * 259 + * To support them, it'd look something like this: 260 + * this.pastMessages.set(log.message.id, log.message) 261 + */ 262 + this.pastMessages.delete(log.message.id) 263 + } 264 + } 265 + } 266 + } 267 + } 268 + 269 + this.commit() 270 + } 271 + 272 + async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { 273 + if (this.status === ConvoStatus.Destroyed) return 274 + // Ignore empty messages for now since they have no other purpose atm 275 + if (!message.text) return 276 + 277 + const tempId = nanoid() 278 + 279 + this.pendingMessages.set(tempId, { 280 + id: tempId, 281 + message, 282 + }) 283 + this.commit() 284 + 285 + await new Promise(y => setTimeout(y, 500)) 286 + const response = await this.agent.api.chat.bsky.convo.sendMessage( 287 + { 288 + convoId: this.convoId, 289 + message, 290 + }, 291 + { 292 + encoding: 'application/json', 293 + headers: { 294 + Authorization: this.__tempFromUserDid, 295 + }, 296 + }, 297 + ) 298 + const res = response.data 299 + 300 + /* 301 + * Insert into `newMessages` as soon as we have a real ID. That way, when 302 + * we get an event log back, we can replace in situ. 303 + */ 304 + this.newMessages.set(res.id, { 305 + ...res, 306 + $type: 'chat.bsky.convo.defs#messageView', 307 + sender: this.convo?.members.find(m => m.did === this.__tempFromUserDid), 308 + }) 309 + this.pendingMessages.delete(tempId) 310 + 311 + this.commit() 312 + } 313 + 314 + /* 315 + * Items in reverse order, since FlatList inverts 316 + */ 317 + get items(): ConvoItem[] { 318 + const items: ConvoItem[] = [] 319 + 320 + // `newMessages` is in insertion order, unshift to reverse 321 + this.newMessages.forEach(m => { 322 + if (ChatBskyConvoDefs.isMessageView(m)) { 323 + items.unshift({ 324 + type: 'message', 325 + key: m.id, 326 + message: m, 327 + nextMessage: null, 328 + }) 329 + } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { 330 + items.unshift({ 331 + type: 'deleted-message', 332 + key: m.id, 333 + message: m, 334 + nextMessage: null, 335 + }) 336 + } 337 + }) 338 + 339 + // `newMessages` is in insertion order, unshift to reverse 340 + this.pendingMessages.forEach(m => { 341 + items.unshift({ 342 + type: 'pending-message', 343 + key: m.id, 344 + message: m.message, 345 + }) 346 + }) 347 + 348 + this.pastMessages.forEach(m => { 349 + if (ChatBskyConvoDefs.isMessageView(m)) { 350 + items.push({ 351 + type: 'message', 352 + key: m.id, 353 + message: m, 354 + nextMessage: null, 355 + }) 356 + } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { 357 + items.push({ 358 + type: 'deleted-message', 359 + key: m.id, 360 + message: m, 361 + nextMessage: null, 362 + }) 363 + } 364 + }) 365 + 366 + return items.map((item, i) => { 367 + let nextMessage = null 368 + 369 + if ( 370 + ChatBskyConvoDefs.isMessageView(item.message) || 371 + ChatBskyConvoDefs.isDeletedMessageView(item.message) 372 + ) { 373 + const next = items[i - 1] 374 + if ( 375 + next && 376 + (ChatBskyConvoDefs.isMessageView(next.message) || 377 + ChatBskyConvoDefs.isDeletedMessageView(next.message)) 378 + ) { 379 + nextMessage = next.message 380 + } 381 + } 382 + 383 + return { 384 + ...item, 385 + nextMessage, 386 + } 387 + }) 388 + } 389 + 390 + destroy() { 391 + this.status = ConvoStatus.Destroyed 392 + this.commit() 393 + } 394 + 395 + get state(): ConvoState { 396 + switch (this.status) { 397 + case ConvoStatus.Initializing: { 398 + return { 399 + status: ConvoStatus.Initializing, 400 + } 401 + } 402 + case ConvoStatus.Ready: { 403 + return { 404 + status: ConvoStatus.Ready, 405 + items: this.items, 406 + convo: this.convo!, 407 + isFetchingHistory: this.isFetchingHistory, 408 + } 409 + } 410 + case ConvoStatus.Error: { 411 + return { 412 + status: ConvoStatus.Error, 413 + error: this.error, 414 + } 415 + } 416 + case ConvoStatus.Destroyed: { 417 + return { 418 + status: ConvoStatus.Destroyed, 419 + } 420 + } 421 + default: { 422 + return { 423 + status: ConvoStatus.Uninitialized, 424 + } 425 + } 426 + } 427 + } 428 + 429 + private _emitter = new EventEmitter() 430 + 431 + private commit() { 432 + this._emitter.emit('update') 433 + } 434 + 435 + on(event: 'update', cb: () => void) { 436 + this._emitter.on(event, cb) 437 + } 438 + 439 + off(event: 'update', cb: () => void) { 440 + this._emitter.off(event, cb) 441 + } 442 + }
+57
src/state/messages/index.tsx
··· 1 + import React from 'react' 2 + import {BskyAgent} from '@atproto-labs/api' 3 + 4 + import {Convo, ConvoParams} from '#/state/messages/convo' 5 + import {useAgent} from '#/state/session' 6 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 7 + 8 + const ChatContext = React.createContext<{ 9 + service: Convo 10 + state: Convo['state'] 11 + }>({ 12 + // @ts-ignore 13 + service: null, 14 + // @ts-ignore 15 + state: null, 16 + }) 17 + 18 + export function useChat() { 19 + return React.useContext(ChatContext) 20 + } 21 + 22 + export function ChatProvider({ 23 + children, 24 + convoId, 25 + }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { 26 + const {serviceUrl} = useDmServiceUrlStorage() 27 + const {getAgent} = useAgent() 28 + const [service] = React.useState( 29 + () => 30 + new Convo({ 31 + convoId, 32 + agent: new BskyAgent({ 33 + service: serviceUrl, 34 + }), 35 + __tempFromUserDid: getAgent().session?.did!, 36 + }), 37 + ) 38 + const [state, setState] = React.useState(service.state) 39 + 40 + React.useEffect(() => { 41 + service.initialize() 42 + }, [service]) 43 + 44 + React.useEffect(() => { 45 + const update = () => setState(service.state) 46 + service.on('update', update) 47 + return () => { 48 + service.destroy() 49 + } 50 + }, [service]) 51 + 52 + return ( 53 + <ChatContext.Provider value={{state, service}}> 54 + {children} 55 + </ChatContext.Provider> 56 + ) 57 + }
+25
src/state/queries/messages/conversation.ts
··· 1 + import {BskyAgent} from '@atproto-labs/api' 2 + import {useQuery} from '@tanstack/react-query' 3 + 4 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 + import {useHeaders} from './temp-headers' 6 + 7 + const RQKEY_ROOT = 'convo' 8 + export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] 9 + 10 + export function useConvoQuery(convoId: string) { 11 + const headers = useHeaders() 12 + const {serviceUrl} = useDmServiceUrlStorage() 13 + 14 + return useQuery({ 15 + queryKey: RQKEY(convoId), 16 + queryFn: async () => { 17 + const agent = new BskyAgent({service: serviceUrl}) 18 + const {data} = await agent.api.chat.bsky.convo.getConvo( 19 + {convoId}, 20 + {headers}, 21 + ) 22 + return data.convo 23 + }, 24 + }) 25 + }
+35
src/state/queries/messages/get-convo-for-members.ts
··· 1 + import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 + import {RQKEY as CONVO_KEY} from './conversation' 6 + import {useHeaders} from './temp-headers' 7 + 8 + export function useGetConvoForMembers({ 9 + onSuccess, 10 + onError, 11 + }: { 12 + onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void 13 + onError?: (error: Error) => void 14 + }) { 15 + const queryClient = useQueryClient() 16 + const headers = useHeaders() 17 + const {serviceUrl} = useDmServiceUrlStorage() 18 + 19 + return useMutation({ 20 + mutationFn: async (members: string[]) => { 21 + const agent = new BskyAgent({service: serviceUrl}) 22 + const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( 23 + {members: members}, 24 + {headers}, 25 + ) 26 + 27 + return data 28 + }, 29 + onSuccess: data => { 30 + queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) 31 + onSuccess?.(data) 32 + }, 33 + onError, 34 + }) 35 + }
+28
src/state/queries/messages/list-converations.ts
··· 1 + import {BskyAgent} from '@atproto-labs/api' 2 + import {useInfiniteQuery} from '@tanstack/react-query' 3 + 4 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 + import {useHeaders} from './temp-headers' 6 + 7 + export const RQKEY = ['convo-list'] 8 + type RQPageParam = string | undefined 9 + 10 + export function useListConvos() { 11 + const headers = useHeaders() 12 + const {serviceUrl} = useDmServiceUrlStorage() 13 + 14 + return useInfiniteQuery({ 15 + queryKey: RQKEY, 16 + queryFn: async ({pageParam}) => { 17 + const agent = new BskyAgent({service: serviceUrl}) 18 + const {data} = await agent.api.chat.bsky.convo.listConvos( 19 + {cursor: pageParam}, 20 + {headers}, 21 + ) 22 + 23 + return data 24 + }, 25 + initialPageParam: undefined as RQPageParam, 26 + getNextPageParam: lastPage => lastPage.cursor, 27 + }) 28 + }
+11
src/state/queries/messages/temp-headers.ts
··· 1 + import {useSession} from '#/state/session' 2 + 3 + // toy auth 4 + export const useHeaders = () => { 5 + const {currentAccount} = useSession() 6 + return { 7 + get Authorization() { 8 + return currentAccount!.did 9 + }, 10 + } 11 + }
-195
src/temp/dm/defs.ts
··· 1 - import { 2 - AppBskyActorDefs, 3 - AppBskyEmbedRecord, 4 - AppBskyRichtextFacet, 5 - } from '@atproto/api' 6 - import {ValidationResult} from '@atproto/lexicon' 7 - 8 - export interface Message { 9 - id?: string 10 - text: string 11 - /** Annotations of text (mentions, URLs, hashtags, etc) */ 12 - facets?: AppBskyRichtextFacet.Main[] 13 - embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown} 14 - [k: string]: unknown 15 - } 16 - 17 - export function isMessage(v: unknown): v is Message { 18 - return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message' 19 - } 20 - 21 - export function validateMessage(v: unknown): ValidationResult { 22 - return { 23 - success: true, 24 - value: v, 25 - } 26 - } 27 - 28 - export interface MessageView { 29 - id: string 30 - rev: string 31 - text: string 32 - /** Annotations of text (mentions, URLs, hashtags, etc) */ 33 - facets?: AppBskyRichtextFacet.Main[] 34 - embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown} 35 - sender?: MessageViewSender 36 - sentAt: string 37 - [k: string]: unknown 38 - } 39 - 40 - export function isMessageView(v: unknown): v is MessageView { 41 - return ( 42 - isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView' 43 - ) 44 - } 45 - 46 - export function validateMessageView(v: unknown): ValidationResult { 47 - return { 48 - success: true, 49 - value: v, 50 - } 51 - } 52 - 53 - export interface DeletedMessage { 54 - id: string 55 - rev?: string 56 - sender?: MessageViewSender 57 - sentAt: string 58 - [k: string]: unknown 59 - } 60 - 61 - export function isDeletedMessage(v: unknown): v is DeletedMessage { 62 - return ( 63 - isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage' 64 - ) 65 - } 66 - 67 - export function validateDeletedMessage(v: unknown): ValidationResult { 68 - return { 69 - success: true, 70 - value: v, 71 - } 72 - } 73 - 74 - export interface MessageViewSender { 75 - did: string 76 - [k: string]: unknown 77 - } 78 - 79 - export function isMessageViewSender(v: unknown): v is MessageViewSender { 80 - return ( 81 - isObj(v) && 82 - hasProp(v, '$type') && 83 - v.$type === 'temp.dm.defs#messageViewSender' 84 - ) 85 - } 86 - 87 - export function validateMessageViewSender(v: unknown): ValidationResult { 88 - return { 89 - success: true, 90 - value: v, 91 - } 92 - } 93 - 94 - export interface ChatView { 95 - id: string 96 - rev: string 97 - members: AppBskyActorDefs.ProfileViewBasic[] 98 - lastMessage?: 99 - | MessageView 100 - | DeletedMessage 101 - | {$type: string; [k: string]: unknown} 102 - unreadCount: number 103 - [k: string]: unknown 104 - } 105 - 106 - export function isChatView(v: unknown): v is ChatView { 107 - return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView' 108 - } 109 - 110 - export function validateChatView(v: unknown): ValidationResult { 111 - return { 112 - success: true, 113 - value: v, 114 - } 115 - } 116 - 117 - export type IncomingMessageSetting = 118 - | 'all' 119 - | 'none' 120 - | 'following' 121 - | (string & {}) 122 - 123 - export interface LogBeginChat { 124 - rev: string 125 - chatId: string 126 - [k: string]: unknown 127 - } 128 - 129 - export function isLogBeginChat(v: unknown): v is LogBeginChat { 130 - return ( 131 - isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat' 132 - ) 133 - } 134 - 135 - export function validateLogBeginChat(v: unknown): ValidationResult { 136 - return { 137 - success: true, 138 - value: v, 139 - } 140 - } 141 - 142 - export interface LogCreateMessage { 143 - rev: string 144 - chatId: string 145 - message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown} 146 - [k: string]: unknown 147 - } 148 - 149 - export function isLogCreateMessage(v: unknown): v is LogCreateMessage { 150 - return ( 151 - isObj(v) && 152 - hasProp(v, '$type') && 153 - v.$type === 'temp.dm.defs#logCreateMessage' 154 - ) 155 - } 156 - 157 - export function validateLogCreateMessage(v: unknown): ValidationResult { 158 - return { 159 - success: true, 160 - value: v, 161 - } 162 - } 163 - 164 - export interface LogDeleteMessage { 165 - rev: string 166 - chatId: string 167 - message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown} 168 - [k: string]: unknown 169 - } 170 - 171 - export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage { 172 - return ( 173 - isObj(v) && 174 - hasProp(v, '$type') && 175 - v.$type === 'temp.dm.defs#logDeleteMessage' 176 - ) 177 - } 178 - 179 - export function validateLogDeleteMessage(v: unknown): ValidationResult { 180 - return { 181 - success: true, 182 - value: v, 183 - } 184 - } 185 - 186 - export function isObj(v: unknown): v is Record<string, unknown> { 187 - return typeof v === 'object' && v !== null 188 - } 189 - 190 - export function hasProp<K extends PropertyKey>( 191 - data: object, 192 - prop: K, 193 - ): data is Record<K, unknown> { 194 - return prop in data 195 - }
-31
src/temp/dm/deleteMessage.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams {} 6 - 7 - export interface InputSchema { 8 - chatId: string 9 - messageId: string 10 - [k: string]: unknown 11 - } 12 - 13 - export type OutputSchema = TempDmDefs.DeletedMessage 14 - 15 - export interface CallOptions { 16 - headers?: Headers 17 - qp?: QueryParams 18 - encoding: 'application/json' 19 - } 20 - 21 - export interface Response { 22 - success: boolean 23 - headers: Headers 24 - data: OutputSchema 25 - } 26 - 27 - export function toKnownErr(e: any) { 28 - if (e instanceof XRPCError) { 29 - } 30 - return e 31 - }
-30
src/temp/dm/getChat.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams { 6 - chatId: string 7 - } 8 - 9 - export type InputSchema = undefined 10 - 11 - export interface OutputSchema { 12 - chat: TempDmDefs.ChatView 13 - [k: string]: unknown 14 - } 15 - 16 - export interface CallOptions { 17 - headers?: Headers 18 - } 19 - 20 - export interface Response { 21 - success: boolean 22 - headers: Headers 23 - data: OutputSchema 24 - } 25 - 26 - export function toKnownErr(e: any) { 27 - if (e instanceof XRPCError) { 28 - } 29 - return e 30 - }
-30
src/temp/dm/getChatForMembers.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams { 6 - members: string[] 7 - } 8 - 9 - export type InputSchema = undefined 10 - 11 - export interface OutputSchema { 12 - chat: TempDmDefs.ChatView 13 - [k: string]: unknown 14 - } 15 - 16 - export interface CallOptions { 17 - headers?: Headers 18 - } 19 - 20 - export interface Response { 21 - success: boolean 22 - headers: Headers 23 - data: OutputSchema 24 - } 25 - 26 - export function toKnownErr(e: any) { 27 - if (e instanceof XRPCError) { 28 - } 29 - return e 30 - }
-36
src/temp/dm/getChatLog.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams { 6 - cursor?: string 7 - } 8 - 9 - export type InputSchema = undefined 10 - 11 - export interface OutputSchema { 12 - cursor?: string 13 - logs: ( 14 - | TempDmDefs.LogBeginChat 15 - | TempDmDefs.LogCreateMessage 16 - | TempDmDefs.LogDeleteMessage 17 - | {$type: string; [k: string]: unknown} 18 - )[] 19 - [k: string]: unknown 20 - } 21 - 22 - export interface CallOptions { 23 - headers?: Headers 24 - } 25 - 26 - export interface Response { 27 - success: boolean 28 - headers: Headers 29 - data: OutputSchema 30 - } 31 - 32 - export function toKnownErr(e: any) { 33 - if (e instanceof XRPCError) { 34 - } 35 - return e 36 - }
-37
src/temp/dm/getChatMessages.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams { 6 - chatId: string 7 - limit?: number 8 - cursor?: string 9 - } 10 - 11 - export type InputSchema = undefined 12 - 13 - export interface OutputSchema { 14 - cursor?: string 15 - messages: ( 16 - | TempDmDefs.MessageView 17 - | TempDmDefs.DeletedMessage 18 - | {$type: string; [k: string]: unknown} 19 - )[] 20 - [k: string]: unknown 21 - } 22 - 23 - export interface CallOptions { 24 - headers?: Headers 25 - } 26 - 27 - export interface Response { 28 - success: boolean 29 - headers: Headers 30 - data: OutputSchema 31 - } 32 - 33 - export function toKnownErr(e: any) { 34 - if (e instanceof XRPCError) { 35 - } 36 - return e 37 - }
-28
src/temp/dm/getUserSettings.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams {} 6 - 7 - export type InputSchema = undefined 8 - 9 - export interface OutputSchema { 10 - allowIncoming: TempDmDefs.IncomingMessageSetting 11 - [k: string]: unknown 12 - } 13 - 14 - export interface CallOptions { 15 - headers?: Headers 16 - } 17 - 18 - export interface Response { 19 - success: boolean 20 - headers: Headers 21 - data: OutputSchema 22 - } 23 - 24 - export function toKnownErr(e: any) { 25 - if (e instanceof XRPCError) { 26 - } 27 - return e 28 - }
-30
src/temp/dm/leaveChat.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - export interface QueryParams {} 4 - 5 - export interface InputSchema { 6 - chatId: string 7 - [k: string]: unknown 8 - } 9 - 10 - export interface OutputSchema { 11 - [k: string]: unknown 12 - } 13 - 14 - export interface CallOptions { 15 - headers?: Headers 16 - qp?: QueryParams 17 - encoding: 'application/json' 18 - } 19 - 20 - export interface Response { 21 - success: boolean 22 - headers: Headers 23 - data: OutputSchema 24 - } 25 - 26 - export function toKnownErr(e: any) { 27 - if (e instanceof XRPCError) { 28 - } 29 - return e 30 - }
-32
src/temp/dm/listChats.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams { 6 - limit?: number 7 - cursor?: string 8 - } 9 - 10 - export type InputSchema = undefined 11 - 12 - export interface OutputSchema { 13 - cursor?: string 14 - chats: TempDmDefs.ChatView[] 15 - [k: string]: unknown 16 - } 17 - 18 - export interface CallOptions { 19 - headers?: Headers 20 - } 21 - 22 - export interface Response { 23 - success: boolean 24 - headers: Headers 25 - data: OutputSchema 26 - } 27 - 28 - export function toKnownErr(e: any) { 29 - if (e instanceof XRPCError) { 30 - } 31 - return e 32 - }
-30
src/temp/dm/muteChat.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - export interface QueryParams {} 4 - 5 - export interface InputSchema { 6 - chatId: string 7 - [k: string]: unknown 8 - } 9 - 10 - export interface OutputSchema { 11 - [k: string]: unknown 12 - } 13 - 14 - export interface CallOptions { 15 - headers?: Headers 16 - qp?: QueryParams 17 - encoding: 'application/json' 18 - } 19 - 20 - export interface Response { 21 - success: boolean 22 - headers: Headers 23 - data: OutputSchema 24 - } 25 - 26 - export function toKnownErr(e: any) { 27 - if (e instanceof XRPCError) { 28 - } 29 - return e 30 - }
-31
src/temp/dm/sendMessage.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams {} 6 - 7 - export interface InputSchema { 8 - chatId: string 9 - message: TempDmDefs.Message 10 - [k: string]: unknown 11 - } 12 - 13 - export type OutputSchema = TempDmDefs.MessageView 14 - 15 - export interface CallOptions { 16 - headers?: Headers 17 - qp?: QueryParams 18 - encoding: 'application/json' 19 - } 20 - 21 - export interface Response { 22 - success: boolean 23 - headers: Headers 24 - data: OutputSchema 25 - } 26 - 27 - export function toKnownErr(e: any) { 28 - if (e instanceof XRPCError) { 29 - } 30 - return e 31 - }
-66
src/temp/dm/sendMessageBatch.ts
··· 1 - import {ValidationResult} from '@atproto/lexicon' 2 - import {Headers, XRPCError} from '@atproto/xrpc' 3 - 4 - import * as TempDmDefs from './defs' 5 - 6 - export interface QueryParams {} 7 - 8 - export interface InputSchema { 9 - items: BatchItem[] 10 - [k: string]: unknown 11 - } 12 - 13 - export interface OutputSchema { 14 - items: TempDmDefs.MessageView[] 15 - [k: string]: unknown 16 - } 17 - 18 - export interface CallOptions { 19 - headers?: Headers 20 - qp?: QueryParams 21 - encoding: 'application/json' 22 - } 23 - 24 - export interface Response { 25 - success: boolean 26 - headers: Headers 27 - data: OutputSchema 28 - } 29 - 30 - export function toKnownErr(e: any) { 31 - if (e instanceof XRPCError) { 32 - } 33 - return e 34 - } 35 - 36 - export interface BatchItem { 37 - chatId: string 38 - message: TempDmDefs.Message 39 - [k: string]: unknown 40 - } 41 - 42 - export function isBatchItem(v: unknown): v is BatchItem { 43 - return ( 44 - isObj(v) && 45 - hasProp(v, '$type') && 46 - v.$type === 'temp.dm.sendMessageBatch#batchItem' 47 - ) 48 - } 49 - 50 - export function validateBatchItem(v: unknown): ValidationResult { 51 - return { 52 - success: true, 53 - value: v, 54 - } 55 - } 56 - 57 - export function isObj(v: unknown): v is Record<string, unknown> { 58 - return typeof v === 'object' && v !== null 59 - } 60 - 61 - export function hasProp<K extends PropertyKey>( 62 - data: object, 63 - prop: K, 64 - ): data is Record<K, unknown> { 65 - return prop in data 66 - }
-30
src/temp/dm/unmuteChat.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - export interface QueryParams {} 4 - 5 - export interface InputSchema { 6 - chatId: string 7 - [k: string]: unknown 8 - } 9 - 10 - export interface OutputSchema { 11 - [k: string]: unknown 12 - } 13 - 14 - export interface CallOptions { 15 - headers?: Headers 16 - qp?: QueryParams 17 - encoding: 'application/json' 18 - } 19 - 20 - export interface Response { 21 - success: boolean 22 - headers: Headers 23 - data: OutputSchema 24 - } 25 - 26 - export function toKnownErr(e: any) { 27 - if (e instanceof XRPCError) { 28 - } 29 - return e 30 - }
-31
src/temp/dm/updateChatRead.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams {} 6 - 7 - export interface InputSchema { 8 - chatId: string 9 - messageId?: string 10 - [k: string]: unknown 11 - } 12 - 13 - export type OutputSchema = TempDmDefs.ChatView 14 - 15 - export interface CallOptions { 16 - headers?: Headers 17 - qp?: QueryParams 18 - encoding: 'application/json' 19 - } 20 - 21 - export interface Response { 22 - success: boolean 23 - headers: Headers 24 - data: OutputSchema 25 - } 26 - 27 - export function toKnownErr(e: any) { 28 - if (e instanceof XRPCError) { 29 - } 30 - return e 31 - }
-33
src/temp/dm/updateUserSettings.ts
··· 1 - import {Headers, XRPCError} from '@atproto/xrpc' 2 - 3 - import * as TempDmDefs from './defs' 4 - 5 - export interface QueryParams {} 6 - 7 - export interface InputSchema { 8 - allowIncoming?: TempDmDefs.IncomingMessageSetting 9 - [k: string]: unknown 10 - } 11 - 12 - export interface OutputSchema { 13 - allowIncoming: TempDmDefs.IncomingMessageSetting 14 - [k: string]: unknown 15 - } 16 - 17 - export interface CallOptions { 18 - headers?: Headers 19 - qp?: QueryParams 20 - encoding: 'application/json' 21 - } 22 - 23 - export interface Response { 24 - success: boolean 25 - headers: Headers 26 - data: OutputSchema 27 - } 28 - 29 - export function toKnownErr(e: any) { 30 - if (e instanceof XRPCError) { 31 - } 32 - return e 33 - }
+12
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 + "@atproto-labs/api@^0.12.8-clipclops.0": 38 + version "0.12.8-clipclops.0" 39 + resolved "https://registry.yarnpkg.com/@atproto-labs/api/-/api-0.12.8-clipclops.0.tgz#1c5d41d3396e439a0b645f7e1ccf500cc4b42580" 40 + integrity sha512-YYDtWWk6BR+aRBVja/1v+gceNK81lkmF5bi6O4pTmJhFt/321XATx/ql8uTWta4VnVThoFeNPG6nLr7hs8b9cA== 41 + dependencies: 42 + "@atproto/common-web" "^0.3.0" 43 + "@atproto/lexicon" "^0.4.0" 44 + "@atproto/syntax" "^0.3.0" 45 + "@atproto/xrpc" "^0.5.0" 46 + multiformats "^9.9.0" 47 + tlds "^1.234.0" 48 + 37 49 "@atproto/api@^0.12.3": 38 50 version "0.12.3" 39 51 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17"