From cd46f0f435d69b957272468901c52ea7a3a5e38d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 30 Apr 2024 14:46:00 -0500 Subject: [PATCH] Add Chat service --- .../Messages/Conversation/MessagesList.tsx | 178 +++----- src/screens/Messages/Temp/query/query.ts | 45 +- src/state/dms/__tests__/client.test.ts | 38 ++ src/state/dms/chat.ts | 425 ++++++++++++++++++ src/state/dms/index.tsx | 57 +++ 5 files changed, 608 insertions(+), 135 deletions(-) create mode 100644 src/state/dms/__tests__/client.test.ts create mode 100644 src/state/dms/chat.ts create mode 100644 src/state/dms/index.tsx diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 25aaf28c4e..6ec5ec210d 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,24 +1,15 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react' +import React, {useCallback, useMemo, useRef} from 'react' import {FlatList, View, ViewToken} from 'react-native' -import {Alert} from 'react-native' import {KeyboardAvoidingView} from 'react-native-keyboard-controller' -import {isWeb} from '#/platform/detection' +import {useChat} from '#/state/dms' +import {ChatProvider} from '#/state/dms' +import {ChatItem, ChatStatus} from '#/state/dms/chat' +import {isWeb} from 'platform/detection' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' -import { - useChat, - useChatLogQuery, - useSendMessageMutation, -} from '#/screens/Messages/Temp/query/query' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import * as TempDmChatDefs from '#/temp/dm/defs' - -type MessageWithNext = { - message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage - next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null -} function MaybeLoader({isLoading}: {isLoading: boolean}) { return ( @@ -34,47 +25,43 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) { ) } -function renderItem({item}: {item: MessageWithNext}) { - if (TempDmChatDefs.isMessageView(item.message)) - return - - if (TempDmChatDefs.isDeletedMessage(item)) return Deleted message +function renderItem({item}: {item: ChatItem}) { + if (item.type === 'message') { + return + } else if (item.type === 'deleted-message') { + return Deleted message + } else if (item.type === 'pending-message') { + return {item.message.text} + } return null } -// TODO rm -// TEMP: This is a temporary function to generate unique keys for mutation placeholders -const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}` - function onScrollToEndFailed() { // Placeholder function. You have to give FlatList something or else it will error. } export function MessagesList({chatId}: {chatId: string}) { + return ( + + + + ) +} + +export function MessagesListInner() { + const chat = useChat() const flatListRef = useRef(null) - - // Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true - // once the request for new posts starts. Then, we will change it back to false after the content size changes. - const isFetching = useRef(false) - // We use this to know if we should scroll after a new clop is added to the list const isAtBottom = useRef(false) // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the // total number of clops // TODO this needs to be set to whatever the initial number of messages is - const totalMessages = useRef(10) + // const totalMessages = useRef(10) // TODO later - const [_, setShowSpinner] = useState(false) - - // Query Data - const {data: chat} = useChat(chatId) - const {mutate: sendMessage} = useSendMessageMutation(chatId) - useChatLogQuery() - const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => { return [ (info: {viewableItems: Array; changed: Array}) => { @@ -93,23 +80,11 @@ export function MessagesList({chatId}: {chatId: string}) { if (isAtBottom.current) { flatListRef.current?.scrollToOffset({offset: 0, animated: true}) } - - isFetching.current = false - setShowSpinner(false) }, []) const onEndReached = useCallback(() => { - if (isFetching.current) return - isFetching.current = true - setShowSpinner(true) - - // Eventually we will add more here when we hit the top through RQuery - // We wouldn't actually use a timeout, but there would be a delay while loading - setTimeout(() => { - // Do something - setShowSpinner(false) - }, 1000) - }, []) + chat.service.fetchMessageHistory() + }, [chat]) const onInputFocus = useCallback(() => { if (!isAtBottom.current) { @@ -117,84 +92,51 @@ export function MessagesList({chatId}: {chatId: string}) { } }, []) - const onSendMessage = useCallback( - async (message: string) => { - if (!message) return - - try { - sendMessage({ - message, - tempId: generateUniqueKey(), - }) - } catch (e: any) { - Alert.alert(e.toString()) - } - }, - [sendMessage], - ) - const onInputBlur = useCallback(() => {}, []) - const messages = useMemo(() => { - if (!chat) return [] - - const filtered = chat.messages - .filter( - ( - message, - ): message is - | TempDmChatDefs.MessageView - | TempDmChatDefs.DeletedMessage => { - return ( - TempDmChatDefs.isMessageView(message) || - TempDmChatDefs.isDeletedMessage(message) - ) - }, - ) - .reduce((acc, message) => { - // convert [n1, n2, n3, ...] to [{message: n1, next: n2}, {message: n2, next: n3}, {message: n3, next: n4}, ...] - - return [...acc, {message, next: acc.at(-1)?.message ?? null}] - }, [] as MessageWithNext[]) - totalMessages.current = filtered.length - - return filtered - }, [chat]) - return ( - item.message.id} - renderItem={renderItem} - contentContainerStyle={{paddingHorizontal: 10}} - inverted={true} - // In the future, we might want to adjust this value. Not very concerning right now as long as we are only - // dealing with text. But whenever we have images or other media and things are taller, we will want to lower - // this...probably. - initialNumToRender={20} - // Same with the max to render per batch. Let's be safe for now though. - maxToRenderPerBatch={25} - removeClippedSubviews={true} - onEndReached={onEndReached} - onScrollToIndexFailed={onScrollToEndFailed} - onContentSizeChange={onContentSizeChange} - onViewableItemsChanged={onViewableItemsChanged} - viewabilityConfig={viewabilityConfig} - maintainVisibleContentPosition={{ - minIndexForVisible: 1, - }} - ListFooterComponent={} - ref={flatListRef} - keyboardDismissMode="none" - /> + {chat.state.status === ChatStatus.Ready && ( + item.key} + renderItem={renderItem} + contentContainerStyle={{paddingHorizontal: 10}} + // In the future, we might want to adjust this value. Not very concerning right now as long as we are only + // dealing with text. But whenever we have images or other media and things are taller, we will want to lower + // this...probably. + initialNumToRender={20} + // Same with the max to render per batch. Let's be safe for now though. + maxToRenderPerBatch={25} + inverted={true} + onEndReached={onEndReached} + onScrollToIndexFailed={onScrollToEndFailed} + onContentSizeChange={onContentSizeChange} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + }} + ListFooterComponent={ + + } + removeClippedSubviews={true} + ref={flatListRef} + keyboardDismissMode="none" + /> + )} + { + chat.service.sendMessage({ + text, + }) + }} onFocus={onInputFocus} onBlur={onInputBlur} /> diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts index a4d78e0bb7..4d52558777 100644 --- a/src/screens/Messages/Temp/query/query.ts +++ b/src/screens/Messages/Temp/query/query.ts @@ -1,3 +1,5 @@ +import React from 'react' +import {BskyAgent} from '@atproto/api' import { useInfiniteQuery, useMutation, @@ -11,7 +13,6 @@ import * as TempDmChatGetChat from '#/temp/dm/getChat' import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers' import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' -import * as TempDmChatListChats from '#/temp/dm/listChats' import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage' /** @@ -229,19 +230,24 @@ export function useGetChatFromMembers({ const queryClient = useQueryClient() const headers = useHeaders() const {serviceUrl} = useDmServiceUrlStorage() + const [agent] = React.useState(() => { + return new BskyAgent({ + service: serviceUrl, + }) + }) return useMutation({ mutationFn: async (members: string[]) => { - const response = await fetch( - `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join( - ',', - )}`, - {headers}, + const {data} = await agent.api.temp.dm.getChatForMembers( + { + members, + }, + { + headers, + }, ) - if (!response.ok) throw new Error('Failed to fetch chat') - - return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema + return data }, onSuccess: data => { queryClient.setQueryData(['chat', data.chat.id], { @@ -258,20 +264,25 @@ export function useGetChatFromMembers({ export function useListChats() { const headers = useHeaders() const {serviceUrl} = useDmServiceUrlStorage() + const [agent] = React.useState(() => { + return new BskyAgent({ + service: serviceUrl, + }) + }) return useInfiniteQuery({ queryKey: ['chats'], queryFn: async ({pageParam}) => { - const response = await fetch( - `${serviceUrl}/xrpc/temp.dm.listChats${ - pageParam ? `?cursor=${pageParam}` : '' - }`, - {headers}, + const {data} = await agent.api.temp.dm.listChats( + { + cursor: pageParam, + }, + { + headers, + }, ) - if (!response.ok) throw new Error('Failed to fetch chats') - - return (await response.json()) as TempDmChatListChats.OutputSchema + return data }, initialPageParam: undefined as string | undefined, getNextPageParam: lastPage => lastPage.cursor, diff --git a/src/state/dms/__tests__/client.test.ts b/src/state/dms/__tests__/client.test.ts new file mode 100644 index 0000000000..cab1d9021f --- /dev/null +++ b/src/state/dms/__tests__/client.test.ts @@ -0,0 +1,38 @@ +import {describe, it} from '@jest/globals' + +describe(`#/state/dms/client`, () => { + describe(`ChatsService`, () => { + describe(`unread count`, () => { + it.todo(`marks a chat as read, decrements total unread count`) + }) + + describe(`log processing`, () => { + /* + * We receive a new chat log AND messages for it in the same batch. We + * need to first initialize the chat, then process the received logs. + */ + describe(`handles new chats and subsequent messages received in same log batch`, () => { + it.todo(`receives new chat and messages`) + it.todo( + `receives new chat, new messages come in while still initializing new chat`, + ) + }) + }) + + describe(`reset state`, () => { + it.todo(`after period of inactivity, rehydrates entirely fresh state`) + }) + }) + + describe(`ChatService`, () => { + describe(`history fetching`, () => { + it.todo(`fetches initial chat history`) + it.todo(`fetches additional chat history`) + it.todo(`handles history fetch failure`) + }) + + describe(`optimistic updates`, () => { + it.todo(`adds sending messages`) + }) + }) +}) diff --git a/src/state/dms/chat.ts b/src/state/dms/chat.ts new file mode 100644 index 0000000000..9b9b7b6635 --- /dev/null +++ b/src/state/dms/chat.ts @@ -0,0 +1,425 @@ +import {BskyAgent, TempDmDefs, TempDmSendMessage} from '@atproto/api' +import {EventEmitter} from 'eventemitter3' +import {nanoid} from 'nanoid/non-secure' + +export type ChatParams = { + chatId: string + agent: BskyAgent + __tempFromUserDid: string +} + +export enum ChatStatus { + Uninitialized = 'uninitialized', + Initializing = 'initializing', + Ready = 'ready', + Error = 'error', + Destroyed = 'destroyed', +} + +export type ChatItem = + | { + type: 'message' + key: string + message: TempDmDefs.MessageView + nextMessage: TempDmDefs.MessageView | TempDmDefs.DeletedMessage | null + } + | { + type: 'deleted-message' + key: string + message: TempDmDefs.DeletedMessage + nextMessage: TempDmDefs.MessageView | TempDmDefs.DeletedMessage | null + } + | { + type: 'pending-message' + key: string + message: TempDmSendMessage.InputSchema['message'] + } + +export type ChatState = + | { + status: ChatStatus.Uninitialized + } + | { + status: ChatStatus.Initializing + } + | { + status: ChatStatus.Ready + items: ChatItem[] + chat: TempDmDefs.ChatView + isFetchingHistory: boolean + } + | { + status: ChatStatus.Error + error: any + } + | { + status: ChatStatus.Destroyed + } + +export class Chat { + private chatId: string + private agent: BskyAgent + private __tempFromUserDid: string + + private status: ChatStatus = ChatStatus.Uninitialized + private error: any + private chat: TempDmDefs.ChatView | undefined + private historyCursor: string | undefined | null = undefined + private isFetchingHistory = false + private eventsCursor: string | undefined = undefined + + private pastMessages: Map< + string, + TempDmDefs.MessageView | TempDmDefs.DeletedMessage + > = new Map() + private newMessages: Map< + string, + TempDmDefs.MessageView | TempDmDefs.DeletedMessage + > = new Map() + private deletedMessages: TempDmDefs.DeletedMessage[] = [] + private pendingMessages: Map< + string, + {id: string; message: TempDmSendMessage.InputSchema['message']} + > = new Map() + + private pendingEventIngestion: Promise | undefined + + constructor(params: ChatParams) { + this.chatId = params.chatId + this.agent = params.agent + this.__tempFromUserDid = params.__tempFromUserDid + } + + async initialize() { + if (this.status !== 'uninitialized') return + this.status = ChatStatus.Initializing + + try { + const response = await this.agent.api.temp.dm.getChat( + { + chatId: this.chatId, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {chat} = response.data + + this.chat = chat + this.status = ChatStatus.Ready + + this.commit() + + await this.fetchMessageHistory() + + this.pollEvents() + } catch (e) { + this.status = ChatStatus.Error + this.error = e + } + } + + private async pollEvents() { + if (this.status === ChatStatus.Destroyed) return + if (this.pendingEventIngestion) return + setTimeout(async () => { + this.pendingEventIngestion = this.ingestLatestEvents() + await this.pendingEventIngestion + this.pendingEventIngestion = undefined + this.pollEvents() + }, 5e3) + } + + async fetchMessageHistory() { + if (this.status === ChatStatus.Destroyed) return + // reached end + if (this.historyCursor === null) return + if (this.isFetchingHistory) return + + this.isFetchingHistory = true + this.commit() + + /* + * Delay if paginating while scrolled. + * + * TODO why does the FlatList jump without this delay? + * + * Tbh it feels a little more natural with a slight delay. + */ + if (this.pastMessages.size > 0) { + await new Promise(y => setTimeout(y, 500)) + } + + const response = await this.agent.api.temp.dm.getChatMessages( + { + cursor: this.historyCursor, + chatId: this.chatId, + limit: 20, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {cursor, messages} = response.data + + this.historyCursor = cursor || null + + for (const message of messages) { + if ( + TempDmDefs.isMessageView(message) || + TempDmDefs.isDeletedMessage(message) + ) { + this.pastMessages.set(message.id, message) + + // set to latest rev + if ( + // @ts-ignore TODO divy said so + message.rev > (this.eventsCursor = this.eventsCursor || message.rev) + ) { + this.eventsCursor = message.rev + } + } + } + + this.isFetchingHistory = false + this.commit() + } + + async ingestLatestEvents() { + if (this.status === ChatStatus.Destroyed) return + + const response = await this.agent.api.temp.dm.getChatLog( + { + cursor: this.eventsCursor, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {logs} = response.data + + for (const log of logs) { + /* + * If there's a rev, we should handle it. If there's not a rev, we don't + * know what it is. + */ + if (typeof log.rev === 'string') { + /* + * We only care about new events + */ + if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { + /* + * Update rev regardless of if it's a log type we care about or not + */ + this.eventsCursor = log.rev + + /* + * This is VERY important. We don't want to insert any messages from + * your other chats. + * + * TODO there may be a better way to handle this + */ + if (log.chatId !== this.chatId) continue + + if ( + TempDmDefs.isLogCreateMessage(log) && + TempDmDefs.isMessageView(log.message) + ) { + this.newMessages.set(log.message.id, log.message) + } else if ( + TempDmDefs.isLogDeleteMessage(log) && + TempDmDefs.isDeletedMessage(log.message) + ) { + /* + * Update if we have this in state. If we don't, don't worry about it. + */ + if (this.pastMessages.has(log.message.id)) { + this.pastMessages.set(log.message.id, log.message) + } + } + } + } + } + + this.commit() + } + + async sendMessage(message: TempDmSendMessage.InputSchema['message']) { + if (this.status === ChatStatus.Destroyed) return + // Ignore empty messages for now since they have no other purpose atm + if (!message.text) return + + const tempId = nanoid() + + this.pendingMessages.set(tempId, { + id: tempId, + message, + }) + this.commit() + + await new Promise(y => setTimeout(y, 500)) + const response = await this.agent.api.temp.dm.sendMessage( + { + chatId: this.chatId, + message, + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const res = response.data + + /* + * Insert into `newMessages` as soon as we have a real ID. That way, when + * we get an event log back, we can replace in situ. + */ + this.newMessages.set(res.id, { + ...res, + $type: 'temp.dm.defs#messageView', + sender: this.chat?.members.find(m => m.did === this.__tempFromUserDid), + }) + this.pendingMessages.delete(tempId) + + this.commit() + } + + /* + * Items in reverse order, since FlatList inverts + * + * TODO remove `deletedMessages` from these lists + */ + get items(): ChatItem[] { + const items: ChatItem[] = [] + + // `newMessages` is in insertion order, unshift to reverse + this.newMessages.forEach(m => { + if (TempDmDefs.isMessageView(m)) { + items.unshift({ + type: 'message', + key: m.id, + message: m, + nextMessage: null, + }) + } else if (TempDmDefs.isDeletedMessage(m)) { + items.unshift({ + type: 'deleted-message', + key: m.id, + message: m, + nextMessage: null, + }) + } + }) + + // `newMessages` is in insertion order, unshift to reverse + this.pendingMessages.forEach(m => { + items.unshift({ + type: 'pending-message', + key: m.id, + message: m.message, + }) + }) + + this.pastMessages.forEach(m => { + if (TempDmDefs.isMessageView(m)) { + items.push({ + type: 'message', + key: m.id, + message: m, + nextMessage: null, + }) + } else if (TempDmDefs.isDeletedMessage(m)) { + items.push({ + type: 'deleted-message', + key: m.id, + message: m, + nextMessage: null, + }) + } + }) + + return items.map((item, i) => { + let nextMessage = null + + if ( + TempDmDefs.isMessageView(item.message) || + TempDmDefs.isDeletedMessage(item.message) + ) { + const next = items[i - 1] + if ( + next && + (TempDmDefs.isMessageView(next.message) || + TempDmDefs.isDeletedMessage(next.message)) + ) { + nextMessage = next.message + } + } + + return { + ...item, + nextMessage, + } + }) + } + + destroy() { + this.status = ChatStatus.Destroyed + this.commit() + } + + get state(): ChatState { + switch (this.status) { + case ChatStatus.Initializing: { + return { + status: ChatStatus.Initializing, + } + } + case ChatStatus.Ready: { + return { + status: ChatStatus.Ready, + items: this.items, + chat: this.chat!, + isFetchingHistory: this.isFetchingHistory, + } + } + case ChatStatus.Error: { + return { + status: ChatStatus.Error, + error: this.error, + } + } + case ChatStatus.Destroyed: { + return { + status: ChatStatus.Destroyed, + } + } + default: { + return { + status: ChatStatus.Uninitialized, + } + } + } + } + + private _emitter = new EventEmitter() + + private commit() { + this._emitter.emit('update') + } + + on(event: 'update', cb: () => void) { + this._emitter.on(event, cb) + } + + off(event: 'update', cb: () => void) { + this._emitter.off(event, cb) + } +} diff --git a/src/state/dms/index.tsx b/src/state/dms/index.tsx new file mode 100644 index 0000000000..45be8e001f --- /dev/null +++ b/src/state/dms/index.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import {BskyAgent} from '@atproto/api' + +import {Chat, ChatParams} from '#/state/dms/chat' +import {useAgent} from '#/state/session' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' + +const ChatContext = React.createContext<{ + service: Chat + state: Chat['state'] +}>({ + // @ts-ignore + service: null, + // @ts-ignore + state: null, +}) + +export function useChat() { + return React.useContext(ChatContext) +} + +export function ChatProvider({ + children, + chatId, +}: Pick & {children: React.ReactNode}) { + const {serviceUrl} = useDmServiceUrlStorage() + const {getAgent} = useAgent() + const [service] = React.useState( + () => + new Chat({ + chatId, + agent: new BskyAgent({ + service: serviceUrl, + }), + __tempFromUserDid: getAgent().session?.did!, + }), + ) + const [state, setState] = React.useState(service.state) + + React.useEffect(() => { + service.initialize() + }, [service]) + + React.useEffect(() => { + const update = () => setState(service.state) + service.on('update', update) + return () => { + service.destroy() + } + }, [service]) + + return ( + + {children} + + ) +}