diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 9 | ||||
-rw-r--r-- | src/state/messages/__tests__/client.test.ts | 38 | ||||
-rw-r--r-- | src/state/messages/__tests__/convo.test.ts | 57 | ||||
-rw-r--r-- | src/state/messages/convo.ts | 201 |
4 files changed, 222 insertions, 83 deletions
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index ec7147e3c..d3f9916ec 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -7,6 +7,7 @@ import {ConvoItem, ConvoStatus} from '#/state/messages/convo' import {isWeb} from 'platform/detection' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' +import {Button, ButtonText} from '#/components/Button' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -31,6 +32,14 @@ function renderItem({item}: {item: ConvoItem}) { return <Text>Deleted message</Text> } else if (item.type === 'pending-message') { return <Text>{item.message.text}</Text> + } else if (item.type === 'pending-retry') { + return ( + <View> + <Button label="Retry" onPress={item.retry}> + <ButtonText>Retry</ButtonText> + </Button> + </View> + ) } return null diff --git a/src/state/messages/__tests__/client.test.ts b/src/state/messages/__tests__/client.test.ts deleted file mode 100644 index cab1d9021..000000000 --- a/src/state/messages/__tests__/client.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts new file mode 100644 index 000000000..03f9218ff --- /dev/null +++ b/src/state/messages/__tests__/convo.test.ts @@ -0,0 +1,57 @@ +import {describe, it} from '@jest/globals' + +describe(`#/state/messages/convo`, () => { + describe(`status states`, () => { + it.todo(`cannot re-initialize from a non-unintialized state`) + it.todo(`can re-initialize from a failed state`) + + describe(`destroy`, () => { + it.todo(`cannot be interacted with when destroyed`) + it.todo(`polling is stopped when destroyed`) + it.todo(`events are cleaned up when destroyed`) + }) + }) + + describe(`history fetching`, () => { + it.todo(`fetches initial chat history`) + it.todo(`fetches additional chat history`) + it.todo(`handles history fetch failure`) + it.todo(`does not insert deleted messages`) + }) + + describe(`sending messages`, () => { + it.todo(`optimistically adds sending messages`) + it.todo(`sends messages in order`) + it.todo(`failed message send fails all sending messages`) + it.todo(`can retry all failed messages via retry ConvoItem`) + it.todo( + `successfully sent messages are re-ordered, if needed, by events received from server`, + ) + }) + + describe(`deleting messages`, () => { + it.todo(`messages are optimistically deleted from the chat`) + it.todo(`messages are confirmed deleted via events from the server`) + }) + + describe(`log handling`, () => { + it.todo(`updates rev to latest message received`) + it.todo(`only handles log events for this convoId`) + it.todo(`does not insert deleted messages`) + }) + + describe(`item ordering`, () => { + it.todo(`pending items are first, and in order`) + it.todo(`new message items are next, and in order`) + it.todo(`past message items are next, and in order`) + }) + + describe(`inactivity`, () => { + it.todo( + `below a certain threshold of inactivity, restore entirely from log`, + ) + it.todo( + `above a certain threshold of inactivity, rehydrate entirely fresh state`, + ) + }) +}) diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts index a1de1dbed..73ef8d73e 100644 --- a/src/state/messages/convo.ts +++ b/src/state/messages/convo.ts @@ -6,6 +6,8 @@ import { import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' +import {isNative} from '#/platform/detection' + export type ConvoParams = { convoId: string agent: BskyAgent @@ -44,6 +46,11 @@ export type ConvoItem = key: string message: ChatBskyConvoSendMessage.InputSchema['message'] } + | { + type: 'pending-retry' + key: string + retry: () => void + } export type ConvoState = | { @@ -66,6 +73,17 @@ export type ConvoState = status: ConvoStatus.Destroyed } +export function isConvoItemMessage( + item: ConvoItem, +): item is ConvoItem & {type: 'message'} { + if (!item) return false + return ( + item.type === 'message' || + item.type === 'deleted-message' || + item.type === 'pending-message' + ) +} + export class Convo { private convoId: string private agent: BskyAgent @@ -90,8 +108,10 @@ export class Convo { string, {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} > = new Map() + private footerItems: Map<string, ConvoItem> = new Map() private pendingEventIngestion: Promise<void> | undefined + private isProcessingPendingMessages = false constructor(params: ConvoParams) { this.convoId = params.convoId @@ -165,7 +185,7 @@ export class Convo { { cursor: this.historyCursor, convoId: this.convoId, - limit: 20, + limit: isNative ? 25 : 50, }, { headers: { @@ -230,8 +250,6 @@ export class Convo { /* * 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.convoId !== this.convoId) continue @@ -241,7 +259,6 @@ export class Convo { ) { if (this.newMessages.has(log.message.id)) { // Trust the log as the source of truth on ordering - // TODO test this this.newMessages.delete(log.message.id) } this.newMessages.set(log.message.id, log.message) @@ -269,10 +286,116 @@ export class Convo { this.commit() } + async processPendingMessages() { + const pendingMessage = Array.from(this.pendingMessages.values()).shift() + + /* + * If there are no pending messages, we're done. + */ + if (!pendingMessage) { + this.isProcessingPendingMessages = false + return + } + + try { + this.isProcessingPendingMessages = true + + // throw new Error('UNCOMMENT TO TEST RETRY') + const {id, message} = pendingMessage + + const response = await this.agent.api.chat.bsky.convo.sendMessage( + { + convoId: this.convoId, + 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: 'chat.bsky.convo.defs#messageView', + sender: this.convo?.members.find(m => m.did === this.__tempFromUserDid), + }) + this.pendingMessages.delete(id) + + await this.processPendingMessages() + + this.commit() + } catch (e) { + this.footerItems.set('pending-retry', { + type: 'pending-retry', + key: 'pending-retry', + retry: this.batchRetryPendingMessages.bind(this), + }) + this.commit() + } + } + + async batchRetryPendingMessages() { + this.footerItems.delete('pending-retry') + this.commit() + + try { + const messageArray = Array.from(this.pendingMessages.values()) + const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch( + { + items: messageArray.map(({message}) => ({ + convoId: this.convoId, + message, + })), + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {items} = 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. + */ + for (const item of items) { + this.newMessages.set(item.id, { + ...item, + $type: 'chat.bsky.convo.defs#messageView', + sender: this.convo?.members.find( + m => m.did === this.__tempFromUserDid, + ), + }) + } + + for (const pendingMessage of messageArray) { + this.pendingMessages.delete(pendingMessage.id) + } + + this.commit() + } catch (e) { + this.footerItems.set('pending-retry', { + type: 'pending-retry', + key: 'pending-retry', + retry: this.batchRetryPendingMessages.bind(this), + }) + this.commit() + } + } + async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { if (this.status === ConvoStatus.Destroyed) return // Ignore empty messages for now since they have no other purpose atm - if (!message.text) return + if (!message.text.trim()) return const tempId = nanoid() @@ -282,33 +405,9 @@ export class Convo { }) this.commit() - await new Promise(y => setTimeout(y, 500)) - const response = await this.agent.api.chat.bsky.convo.sendMessage( - { - convoId: this.convoId, - 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: 'chat.bsky.convo.defs#messageView', - sender: this.convo?.members.find(m => m.did === this.__tempFromUserDid), - }) - this.pendingMessages.delete(tempId) - - this.commit() + if (!this.isProcessingPendingMessages) { + this.processPendingMessages() + } } /* @@ -345,6 +444,10 @@ export class Convo { }) }) + this.footerItems.forEach(item => { + items.unshift(item) + }) + this.pastMessages.forEach(m => { if (ChatBskyConvoDefs.isMessageView(m)) { items.push({ @@ -365,25 +468,33 @@ export class Convo { return items.map((item, i) => { let nextMessage = null + const isMessage = isConvoItemMessage(item) - if ( - ChatBskyConvoDefs.isMessageView(item.message) || - ChatBskyConvoDefs.isDeletedMessageView(item.message) - ) { - const next = items[i - 1] + if (isMessage) { if ( - next && - (ChatBskyConvoDefs.isMessageView(next.message) || - ChatBskyConvoDefs.isDeletedMessageView(next.message)) + isMessage && + (ChatBskyConvoDefs.isMessageView(item.message) || + ChatBskyConvoDefs.isDeletedMessageView(item.message)) ) { - nextMessage = next.message + const next = items[i - 1] + + if ( + isConvoItemMessage(next) && + next && + (ChatBskyConvoDefs.isMessageView(next.message) || + ChatBskyConvoDefs.isDeletedMessageView(next.message)) + ) { + nextMessage = next.message + } } - } - return { - ...item, - nextMessage, + return { + ...item, + nextMessage, + } } + + return item }) } |