···77import {isWeb} from 'platform/detection'
88import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
99import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
1010+import {Button, ButtonText} from '#/components/Button'
1011import {Loader} from '#/components/Loader'
1112import {Text} from '#/components/Typography'
1213···3132 return <Text>Deleted message</Text>
3233 } else if (item.type === 'pending-message') {
3334 return <Text>{item.message.text}</Text>
3535+ } else if (item.type === 'pending-retry') {
3636+ return (
3737+ <View>
3838+ <Button label="Retry" onPress={item.retry}>
3939+ <ButtonText>Retry</ButtonText>
4040+ </Button>
4141+ </View>
4242+ )
3443 }
35443645 return null
-38
src/state/messages/__tests__/client.test.ts
···11-import {describe, it} from '@jest/globals'
22-33-describe(`#/state/dms/client`, () => {
44- describe(`ChatsService`, () => {
55- describe(`unread count`, () => {
66- it.todo(`marks a chat as read, decrements total unread count`)
77- })
88-99- describe(`log processing`, () => {
1010- /*
1111- * We receive a new chat log AND messages for it in the same batch. We
1212- * need to first initialize the chat, then process the received logs.
1313- */
1414- describe(`handles new chats and subsequent messages received in same log batch`, () => {
1515- it.todo(`receives new chat and messages`)
1616- it.todo(
1717- `receives new chat, new messages come in while still initializing new chat`,
1818- )
1919- })
2020- })
2121-2222- describe(`reset state`, () => {
2323- it.todo(`after period of inactivity, rehydrates entirely fresh state`)
2424- })
2525- })
2626-2727- describe(`ChatService`, () => {
2828- describe(`history fetching`, () => {
2929- it.todo(`fetches initial chat history`)
3030- it.todo(`fetches additional chat history`)
3131- it.todo(`handles history fetch failure`)
3232- })
3333-3434- describe(`optimistic updates`, () => {
3535- it.todo(`adds sending messages`)
3636- })
3737- })
3838-})
+57
src/state/messages/__tests__/convo.test.ts
···11+import {describe, it} from '@jest/globals'
22+33+describe(`#/state/messages/convo`, () => {
44+ describe(`status states`, () => {
55+ it.todo(`cannot re-initialize from a non-unintialized state`)
66+ it.todo(`can re-initialize from a failed state`)
77+88+ describe(`destroy`, () => {
99+ it.todo(`cannot be interacted with when destroyed`)
1010+ it.todo(`polling is stopped when destroyed`)
1111+ it.todo(`events are cleaned up when destroyed`)
1212+ })
1313+ })
1414+1515+ describe(`history fetching`, () => {
1616+ it.todo(`fetches initial chat history`)
1717+ it.todo(`fetches additional chat history`)
1818+ it.todo(`handles history fetch failure`)
1919+ it.todo(`does not insert deleted messages`)
2020+ })
2121+2222+ describe(`sending messages`, () => {
2323+ it.todo(`optimistically adds sending messages`)
2424+ it.todo(`sends messages in order`)
2525+ it.todo(`failed message send fails all sending messages`)
2626+ it.todo(`can retry all failed messages via retry ConvoItem`)
2727+ it.todo(
2828+ `successfully sent messages are re-ordered, if needed, by events received from server`,
2929+ )
3030+ })
3131+3232+ describe(`deleting messages`, () => {
3333+ it.todo(`messages are optimistically deleted from the chat`)
3434+ it.todo(`messages are confirmed deleted via events from the server`)
3535+ })
3636+3737+ describe(`log handling`, () => {
3838+ it.todo(`updates rev to latest message received`)
3939+ it.todo(`only handles log events for this convoId`)
4040+ it.todo(`does not insert deleted messages`)
4141+ })
4242+4343+ describe(`item ordering`, () => {
4444+ it.todo(`pending items are first, and in order`)
4545+ it.todo(`new message items are next, and in order`)
4646+ it.todo(`past message items are next, and in order`)
4747+ })
4848+4949+ describe(`inactivity`, () => {
5050+ it.todo(
5151+ `below a certain threshold of inactivity, restore entirely from log`,
5252+ )
5353+ it.todo(
5454+ `above a certain threshold of inactivity, rehydrate entirely fresh state`,
5555+ )
5656+ })
5757+})
+156-45
src/state/messages/convo.ts
···66import {EventEmitter} from 'eventemitter3'
77import {nanoid} from 'nanoid/non-secure'
8899+import {isNative} from '#/platform/detection'
1010+911export type ConvoParams = {
1012 convoId: string
1113 agent: BskyAgent
···4446 key: string
4547 message: ChatBskyConvoSendMessage.InputSchema['message']
4648 }
4949+ | {
5050+ type: 'pending-retry'
5151+ key: string
5252+ retry: () => void
5353+ }
47544855export type ConvoState =
4956 | {
···6673 status: ConvoStatus.Destroyed
6774 }
68757676+export function isConvoItemMessage(
7777+ item: ConvoItem,
7878+): item is ConvoItem & {type: 'message'} {
7979+ if (!item) return false
8080+ return (
8181+ item.type === 'message' ||
8282+ item.type === 'deleted-message' ||
8383+ item.type === 'pending-message'
8484+ )
8585+}
8686+6987export class Convo {
7088 private convoId: string
7189 private agent: BskyAgent
···90108 string,
91109 {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
92110 > = new Map()
111111+ private footerItems: Map<string, ConvoItem> = new Map()
9311294113 private pendingEventIngestion: Promise<void> | undefined
114114+ private isProcessingPendingMessages = false
9511596116 constructor(params: ConvoParams) {
97117 this.convoId = params.convoId
···165185 {
166186 cursor: this.historyCursor,
167187 convoId: this.convoId,
168168- limit: 20,
188188+ limit: isNative ? 25 : 50,
169189 },
170190 {
171191 headers: {
···230250 /*
231251 * This is VERY important. We don't want to insert any messages from
232252 * your other chats.
233233- *
234234- * TODO there may be a better way to handle this
235253 */
236254 if (log.convoId !== this.convoId) continue
237255···241259 ) {
242260 if (this.newMessages.has(log.message.id)) {
243261 // Trust the log as the source of truth on ordering
244244- // TODO test this
245262 this.newMessages.delete(log.message.id)
246263 }
247264 this.newMessages.set(log.message.id, log.message)
···269286 this.commit()
270287 }
271288289289+ async processPendingMessages() {
290290+ const pendingMessage = Array.from(this.pendingMessages.values()).shift()
291291+292292+ /*
293293+ * If there are no pending messages, we're done.
294294+ */
295295+ if (!pendingMessage) {
296296+ this.isProcessingPendingMessages = false
297297+ return
298298+ }
299299+300300+ try {
301301+ this.isProcessingPendingMessages = true
302302+303303+ // throw new Error('UNCOMMENT TO TEST RETRY')
304304+ const {id, message} = pendingMessage
305305+306306+ const response = await this.agent.api.chat.bsky.convo.sendMessage(
307307+ {
308308+ convoId: this.convoId,
309309+ message,
310310+ },
311311+ {
312312+ encoding: 'application/json',
313313+ headers: {
314314+ Authorization: this.__tempFromUserDid,
315315+ },
316316+ },
317317+ )
318318+ const res = response.data
319319+320320+ /*
321321+ * Insert into `newMessages` as soon as we have a real ID. That way, when
322322+ * we get an event log back, we can replace in situ.
323323+ */
324324+ this.newMessages.set(res.id, {
325325+ ...res,
326326+ $type: 'chat.bsky.convo.defs#messageView',
327327+ sender: this.convo?.members.find(m => m.did === this.__tempFromUserDid),
328328+ })
329329+ this.pendingMessages.delete(id)
330330+331331+ await this.processPendingMessages()
332332+333333+ this.commit()
334334+ } catch (e) {
335335+ this.footerItems.set('pending-retry', {
336336+ type: 'pending-retry',
337337+ key: 'pending-retry',
338338+ retry: this.batchRetryPendingMessages.bind(this),
339339+ })
340340+ this.commit()
341341+ }
342342+ }
343343+344344+ async batchRetryPendingMessages() {
345345+ this.footerItems.delete('pending-retry')
346346+ this.commit()
347347+348348+ try {
349349+ const messageArray = Array.from(this.pendingMessages.values())
350350+ const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch(
351351+ {
352352+ items: messageArray.map(({message}) => ({
353353+ convoId: this.convoId,
354354+ message,
355355+ })),
356356+ },
357357+ {
358358+ encoding: 'application/json',
359359+ headers: {
360360+ Authorization: this.__tempFromUserDid,
361361+ },
362362+ },
363363+ )
364364+ const {items} = data
365365+366366+ /*
367367+ * Insert into `newMessages` as soon as we have a real ID. That way, when
368368+ * we get an event log back, we can replace in situ.
369369+ */
370370+ for (const item of items) {
371371+ this.newMessages.set(item.id, {
372372+ ...item,
373373+ $type: 'chat.bsky.convo.defs#messageView',
374374+ sender: this.convo?.members.find(
375375+ m => m.did === this.__tempFromUserDid,
376376+ ),
377377+ })
378378+ }
379379+380380+ for (const pendingMessage of messageArray) {
381381+ this.pendingMessages.delete(pendingMessage.id)
382382+ }
383383+384384+ this.commit()
385385+ } catch (e) {
386386+ this.footerItems.set('pending-retry', {
387387+ type: 'pending-retry',
388388+ key: 'pending-retry',
389389+ retry: this.batchRetryPendingMessages.bind(this),
390390+ })
391391+ this.commit()
392392+ }
393393+ }
394394+272395 async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
273396 if (this.status === ConvoStatus.Destroyed) return
274397 // Ignore empty messages for now since they have no other purpose atm
275275- if (!message.text) return
398398+ if (!message.text.trim()) return
276399277400 const tempId = nanoid()
278401···282405 })
283406 this.commit()
284407285285- await new Promise(y => setTimeout(y, 500))
286286- const response = await this.agent.api.chat.bsky.convo.sendMessage(
287287- {
288288- convoId: this.convoId,
289289- message,
290290- },
291291- {
292292- encoding: 'application/json',
293293- headers: {
294294- Authorization: this.__tempFromUserDid,
295295- },
296296- },
297297- )
298298- const res = response.data
299299-300300- /*
301301- * Insert into `newMessages` as soon as we have a real ID. That way, when
302302- * we get an event log back, we can replace in situ.
303303- */
304304- this.newMessages.set(res.id, {
305305- ...res,
306306- $type: 'chat.bsky.convo.defs#messageView',
307307- sender: this.convo?.members.find(m => m.did === this.__tempFromUserDid),
308308- })
309309- this.pendingMessages.delete(tempId)
310310-311311- this.commit()
408408+ if (!this.isProcessingPendingMessages) {
409409+ this.processPendingMessages()
410410+ }
312411 }
313412314413 /*
···345444 })
346445 })
347446447447+ this.footerItems.forEach(item => {
448448+ items.unshift(item)
449449+ })
450450+348451 this.pastMessages.forEach(m => {
349452 if (ChatBskyConvoDefs.isMessageView(m)) {
350453 items.push({
···365468366469 return items.map((item, i) => {
367470 let nextMessage = null
471471+ const isMessage = isConvoItemMessage(item)
368472369369- if (
370370- ChatBskyConvoDefs.isMessageView(item.message) ||
371371- ChatBskyConvoDefs.isDeletedMessageView(item.message)
372372- ) {
373373- const next = items[i - 1]
473473+ if (isMessage) {
374474 if (
375375- next &&
376376- (ChatBskyConvoDefs.isMessageView(next.message) ||
377377- ChatBskyConvoDefs.isDeletedMessageView(next.message))
475475+ isMessage &&
476476+ (ChatBskyConvoDefs.isMessageView(item.message) ||
477477+ ChatBskyConvoDefs.isDeletedMessageView(item.message))
378478 ) {
379379- nextMessage = next.message
479479+ const next = items[i - 1]
480480+481481+ if (
482482+ isConvoItemMessage(next) &&
483483+ next &&
484484+ (ChatBskyConvoDefs.isMessageView(next.message) ||
485485+ ChatBskyConvoDefs.isDeletedMessageView(next.message))
486486+ ) {
487487+ nextMessage = next.message
488488+ }
380489 }
381381- }
382490383383- return {
384384- ...item,
385385- nextMessage,
491491+ return {
492492+ ...item,
493493+ nextMessage,
494494+ }
386495 }
496496+497497+ return item
387498 })
388499 }
389500