diff options
Diffstat (limited to 'src/state/messages')
-rw-r--r-- | src/state/messages/__tests__/convo.test.ts | 20 | ||||
-rw-r--r-- | src/state/messages/convo.ts | 811 | ||||
-rw-r--r-- | src/state/messages/index.tsx | 36 |
3 files changed, 604 insertions, 263 deletions
diff --git a/src/state/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts index 03f9218ff..44fe16fef 100644 --- a/src/state/messages/__tests__/convo.test.ts +++ b/src/state/messages/__tests__/convo.test.ts @@ -1,15 +1,23 @@ import {describe, it} from '@jest/globals' describe(`#/state/messages/convo`, () => { - describe(`status states`, () => { + describe(`init`, () => { + it.todo(`fails if sender and recipients aren't found`) it.todo(`cannot re-initialize from a non-unintialized state`) it.todo(`can re-initialize from a failed state`) + }) + + describe(`resume`, () => { + it.todo(`restores previous state if resume fails`) + }) + + describe(`suspend`, () => { + it.todo(`cannot be interacted with when suspended`) + it.todo(`polling is stopped when suspended`) + }) - 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(`read states`, () => { + it.todo(`should mark messages as read as they come in`) }) describe(`history fetching`, () => { diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts index 4bc9913f8..81ab94f43 100644 --- a/src/state/messages/convo.ts +++ b/src/state/messages/convo.ts @@ -4,9 +4,9 @@ import { ChatBskyConvoDefs, ChatBskyConvoSendMessage, } from '@atproto-labs/api' -import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' export type ConvoParams = { @@ -18,9 +18,21 @@ export type ConvoParams = { export enum ConvoStatus { Uninitialized = 'uninitialized', Initializing = 'initializing', + Resuming = 'resuming', Ready = 'ready', Error = 'error', - Destroyed = 'destroyed', + Backgrounded = 'backgrounded', + Suspended = 'suspended', +} + +export enum ConvoItemError { + HistoryFailed = 'historyFailed', + ResumeFailed = 'resumeFailed', + PollFailed = 'pollFailed', +} + +export enum ConvoError { + InitFailed = 'initFailed', } export type ConvoItem = @@ -47,28 +59,110 @@ export type ConvoItem = key: string retry: () => void } + | { + type: 'error-recoverable' + key: string + code: ConvoItemError + retry: () => void + } export type ConvoState = | { status: ConvoStatus.Uninitialized + items: [] + convo: undefined + error: undefined + sender: undefined + recipients: undefined + isFetchingHistory: false + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined } | { status: ConvoStatus.Initializing + items: [] + convo: undefined + error: undefined + sender: undefined + recipients: undefined + isFetchingHistory: boolean + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined } | { status: ConvoStatus.Ready items: ConvoItem[] convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => void + fetchMessageHistory: () => void } | { - status: ConvoStatus.Error - error: any + status: ConvoStatus.Suspended + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise<void> + fetchMessageHistory: () => Promise<void> + } + | { + status: ConvoStatus.Backgrounded + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise<void> + fetchMessageHistory: () => Promise<void> + } + | { + status: ConvoStatus.Resuming + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] + isFetchingHistory: boolean + deleteMessage: (messageId: string) => Promise<void> + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => Promise<void> + fetchMessageHistory: () => Promise<void> } | { - status: ConvoStatus.Destroyed + status: ConvoStatus.Error + items: [] + convo: undefined + error: any + sender: undefined + recipients: undefined + isFetchingHistory: false + deleteMessage: undefined + sendMessage: undefined + fetchMessageHistory: undefined } +const ACTIVE_POLL_INTERVAL = 2e3 +const BACKGROUND_POLL_INTERVAL = 10e3 + export function isConvoItemMessage( item: ConvoItem, ): item is ConvoItem & {type: 'message'} { @@ -84,15 +178,19 @@ export class Convo { private agent: BskyAgent private __tempFromUserDid: string + private pollInterval = ACTIVE_POLL_INTERVAL private status: ConvoStatus = ConvoStatus.Uninitialized - private error: any + private error: + | { + code: ConvoError + exception?: Error + retry: () => void + } + | undefined private historyCursor: string | undefined | null = undefined private isFetchingHistory = false private eventsCursor: string | undefined = undefined - - convoId: string - convo: ChatBskyConvoDefs.ConvoView | undefined - sender: AppBskyActorDefs.ProfileViewBasic | undefined + private pollingFailure = false private pastMessages: Map< string, @@ -106,87 +204,214 @@ export class Convo { string, {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} > = new Map() + private deletedMessages: Set<string> = new Set() private footerItems: Map<string, ConvoItem> = new Map() + private headerItems: Map<string, ConvoItem> = new Map() private pendingEventIngestion: Promise<void> | undefined private isProcessingPendingMessages = false + convoId: string + convo: ChatBskyConvoDefs.ConvoView | undefined + sender: AppBskyActorDefs.ProfileViewBasic | undefined + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined + snapshot: ConvoState | undefined + constructor(params: ConvoParams) { this.convoId = params.convoId this.agent = params.agent this.__tempFromUserDid = params.__tempFromUserDid + + this.subscribe = this.subscribe.bind(this) + this.getSnapshot = this.getSnapshot.bind(this) + this.sendMessage = this.sendMessage.bind(this) + this.deleteMessage = this.deleteMessage.bind(this) + this.fetchMessageHistory = this.fetchMessageHistory.bind(this) } - async initialize() { - if (this.status !== 'uninitialized') return - this.status = ConvoStatus.Initializing + private commit() { + this.snapshot = undefined + this.subscribers.forEach(subscriber => subscriber()) + } - try { - const response = await this.agent.api.chat.bsky.convo.getConvo( - { - convoId: this.convoId, - }, - { - headers: { - Authorization: this.__tempFromUserDid, - }, - }, - ) - const {convo} = response.data + private subscribers: (() => void)[] = [] - this.convo = convo - this.sender = this.convo.members.find( - m => m.did === this.__tempFromUserDid, - ) - this.status = ConvoStatus.Ready + subscribe(subscriber: () => void) { + if (this.subscribers.length === 0) this.init() - this.commit() + this.subscribers.push(subscriber) - await this.fetchMessageHistory() + return () => { + this.subscribers = this.subscribers.filter(s => s !== subscriber) + if (this.subscribers.length === 0) this.suspend() + } + } - this.pollEvents() - } catch (e) { - this.status = ConvoStatus.Error - this.error = e + getSnapshot(): ConvoState { + if (!this.snapshot) this.snapshot = this.generateSnapshot() + // logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) + return this.snapshot + } + + private generateSnapshot(): ConvoState { + switch (this.status) { + case ConvoStatus.Initializing: { + return { + status: ConvoStatus.Initializing, + items: [], + convo: undefined, + error: undefined, + sender: undefined, + recipients: undefined, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + case ConvoStatus.Suspended: + case ConvoStatus.Backgrounded: + case ConvoStatus.Resuming: + case ConvoStatus.Ready: { + return { + status: this.status, + items: this.getItems(), + convo: this.convo!, + error: undefined, + sender: this.sender!, + recipients: this.recipients!, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: this.deleteMessage, + sendMessage: this.sendMessage, + fetchMessageHistory: this.fetchMessageHistory, + } + } + case ConvoStatus.Error: { + return { + status: ConvoStatus.Error, + items: [], + convo: undefined, + error: this.error, + sender: undefined, + recipients: undefined, + isFetchingHistory: false, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } + default: { + return { + status: ConvoStatus.Uninitialized, + items: [], + convo: undefined, + error: undefined, + sender: undefined, + recipients: undefined, + isFetchingHistory: false, + deleteMessage: undefined, + sendMessage: undefined, + fetchMessageHistory: undefined, + } + } } } - private async pollEvents() { - if (this.status === ConvoStatus.Destroyed) return - if (this.pendingEventIngestion) return - setTimeout(async () => { - this.pendingEventIngestion = this.ingestLatestEvents() - await this.pendingEventIngestion - this.pendingEventIngestion = undefined - this.pollEvents() - }, 5e3) + async init() { + logger.debug('Convo: init', {}, logger.DebugContext.convo) + + if ( + this.status === ConvoStatus.Uninitialized || + this.status === ConvoStatus.Error + ) { + try { + this.status = ConvoStatus.Initializing + this.commit() + + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.commit() + + await this.fetchMessageHistory() + + this.pollEvents() + } catch (e: any) { + logger.error('Convo: failed to init') + this.error = { + exception: e, + code: ConvoError.InitFailed, + retry: () => { + this.error = undefined + this.init() + }, + } + this.status = ConvoStatus.Error + this.commit() + } + } else { + logger.warn(`Convo: cannot init from ${this.status}`) + } } - async fetchMessageHistory() { - if (this.status === ConvoStatus.Destroyed) return - // reached end - if (this.historyCursor === null) return - if (this.isFetchingHistory) return + async resume() { + logger.debug('Convo: resume', {}, logger.DebugContext.convo) + + if ( + this.status === ConvoStatus.Suspended || + this.status === ConvoStatus.Backgrounded + ) { + const fromStatus = this.status + + try { + this.status = ConvoStatus.Resuming + this.commit() + + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.commit() + + // throw new Error('UNCOMMENT TO TEST RESUME FAILURE') + + this.pollInterval = ACTIVE_POLL_INTERVAL + this.pollEvents() + } catch (e) { + logger.error('Convo: failed to resume') + + this.footerItems.set(ConvoItemError.ResumeFailed, { + type: 'error-recoverable', + key: ConvoItemError.ResumeFailed, + code: ConvoItemError.ResumeFailed, + retry: () => { + this.footerItems.delete(ConvoItemError.ResumeFailed) + this.resume() + }, + }) - this.isFetchingHistory = true + this.status = fromStatus + this.commit() + } + } else { + logger.warn(`Convo: cannot resume from ${this.status}`) + } + } + + async background() { + logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo) + this.status = ConvoStatus.Backgrounded + this.pollInterval = BACKGROUND_POLL_INTERVAL 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)) - } + async suspend() { + logger.debug('Convo: suspended', {}, logger.DebugContext.convo) + this.status = ConvoStatus.Suspended + this.commit() + } - const response = await this.agent.api.chat.bsky.convo.getMessages( + async refreshConvo() { + const response = await this.agent.api.chat.bsky.convo.getConvo( { - cursor: this.historyCursor, convoId: this.convoId, - limit: isNative ? 25 : 50, }, { headers: { @@ -194,100 +419,244 @@ export class Convo { }, }, ) - const {cursor, messages} = response.data + this.convo = response.data.convo + this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid) + this.recipients = this.convo.members.filter( + m => m.did !== this.__tempFromUserDid, + ) + + /* + * Prevent invalid states + */ + if (!this.sender) { + throw new Error('Convo: could not find sender in convo') + } + if (!this.recipients) { + throw new Error('Convo: could not find recipients in convo') + } + } + + async fetchMessageHistory() { + logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo) + + /* + * If historyCursor is null, we've fetched all history. + */ + if (this.historyCursor === null) return - this.historyCursor = cursor || null + /* + * Don't fetch again if a fetch is already in progress + */ + if (this.isFetchingHistory) return - for (const message of messages) { - if ( - ChatBskyConvoDefs.isMessageView(message) || - ChatBskyConvoDefs.isDeletedMessageView(message) - ) { - this.pastMessages.set(message.id, message) + /* + * If we've rendered a retry state for history fetching, exit. Upon retry, + * this will be removed and we'll try again. + */ + if (this.headerItems.has(ConvoItemError.HistoryFailed)) return - // set to latest rev + try { + this.isFetchingHistory = true + this.commit() + + /* + * Delay if paginating while scrolled to prevent momentum scrolling from + * jerking the list around, plus makes it feel a little more human. + */ + if (this.pastMessages.size > 0) { + await new Promise(y => setTimeout(y, 500)) + // throw new Error('UNCOMMENT TO TEST RETRY') + } + + const response = await this.agent.api.chat.bsky.convo.getMessages( + { + cursor: this.historyCursor, + convoId: this.convoId, + limit: isNative ? 25 : 50, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + const {cursor, messages} = response.data + + this.historyCursor = cursor ?? null + + for (const message of messages) { if ( - message.rev > (this.eventsCursor = this.eventsCursor || message.rev) + ChatBskyConvoDefs.isMessageView(message) || + ChatBskyConvoDefs.isDeletedMessageView(message) ) { - this.eventsCursor = message.rev + this.pastMessages.set(message.id, message) + + // set to latest rev + if ( + message.rev > (this.eventsCursor = this.eventsCursor || message.rev) + ) { + this.eventsCursor = message.rev + } } } + } catch (e: any) { + logger.error('Convo: failed to fetch message history') + + this.headerItems.set(ConvoItemError.HistoryFailed, { + type: 'error-recoverable', + key: ConvoItemError.HistoryFailed, + code: ConvoItemError.HistoryFailed, + retry: () => { + this.headerItems.delete(ConvoItemError.HistoryFailed) + this.fetchMessageHistory() + }, + }) + } finally { + this.isFetchingHistory = false + this.commit() } + } - this.isFetchingHistory = false - this.commit() + private async pollEvents() { + if ( + this.status === ConvoStatus.Ready || + this.status === ConvoStatus.Backgrounded + ) { + if (this.pendingEventIngestion) return + + /* + * Represents a failed state, which is retryable. + */ + if (this.pollingFailure) return + + setTimeout(async () => { + this.pendingEventIngestion = this.ingestLatestEvents() + await this.pendingEventIngestion + this.pendingEventIngestion = undefined + this.pollEvents() + }, this.pollInterval) + } } async ingestLatestEvents() { - if (this.status === ConvoStatus.Destroyed) return - - const response = await this.agent.api.chat.bsky.convo.getLog( - { - cursor: this.eventsCursor, - }, - { - headers: { - Authorization: this.__tempFromUserDid, + try { + // throw new Error('UNCOMMENT TO TEST POLL FAILURE') + const response = await this.agent.api.chat.bsky.convo.getLog( + { + cursor: this.eventsCursor, }, - }, - ) - const {logs} = response.data + { + 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') { + let needsCommit = false + + for (const log of logs) { /* - * We only care about new events + * If there's a rev, we should handle it. If there's not a rev, we don't + * know what it is. */ - if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { + if (typeof log.rev === 'string') { /* - * Update rev regardless of if it's a log type we care about or not + * We only care about new events */ - this.eventsCursor = log.rev - - /* - * This is VERY important. We don't want to insert any messages from - * your other chats. - */ - if (log.convoId !== this.convoId) continue + 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 - if ( - ChatBskyConvoDefs.isLogCreateMessage(log) && - ChatBskyConvoDefs.isMessageView(log.message) - ) { - if (this.newMessages.has(log.message.id)) { - // Trust the log as the source of truth on ordering - this.newMessages.delete(log.message.id) - } - this.newMessages.set(log.message.id, log.message) - } else if ( - ChatBskyConvoDefs.isLogDeleteMessage(log) && - ChatBskyConvoDefs.isDeletedMessageView(log.message) - ) { /* - * Update if we have this in state. If we don't, don't worry about it. + * This is VERY important. We don't want to insert any messages from + * your other chats. */ - if (this.pastMessages.has(log.message.id)) { + if (log.convoId !== this.convoId) continue + + if ( + ChatBskyConvoDefs.isLogCreateMessage(log) && + ChatBskyConvoDefs.isMessageView(log.message) + ) { + if (this.newMessages.has(log.message.id)) { + // Trust the log as the source of truth on ordering + this.newMessages.delete(log.message.id) + } + this.newMessages.set(log.message.id, log.message) + needsCommit = true + } else if ( + ChatBskyConvoDefs.isLogDeleteMessage(log) && + ChatBskyConvoDefs.isDeletedMessageView(log.message) + ) { /* - * For now, we remove deleted messages from the thread, if we receive one. - * - * To support them, it'd look something like this: - * this.pastMessages.set(log.message.id, log.message) + * Update if we have this in state. If we don't, don't worry about it. */ - this.pastMessages.delete(log.message.id) + if (this.pastMessages.has(log.message.id)) { + /* + * For now, we remove deleted messages from the thread, if we receive one. + * + * To support them, it'd look something like this: + * this.pastMessages.set(log.message.id, log.message) + */ + this.pastMessages.delete(log.message.id) + this.newMessages.delete(log.message.id) + this.deletedMessages.delete(log.message.id) + needsCommit = true + } } } } } + + if (needsCommit) { + this.commit() + } + } catch (e: any) { + logger.error('Convo: failed to poll events') + this.pollingFailure = true + this.footerItems.set(ConvoItemError.PollFailed, { + type: 'error-recoverable', + key: ConvoItemError.PollFailed, + code: ConvoItemError.PollFailed, + retry: () => { + this.footerItems.delete(ConvoItemError.PollFailed) + this.pollingFailure = false + this.commit() + this.pollEvents() + }, + }) + this.commit() } + } + + async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { + // Ignore empty messages for now since they have no other purpose atm + if (!message.text.trim()) return + + logger.debug('Convo: send message', {}, logger.DebugContext.convo) + const tempId = nanoid() + + this.pendingMessages.set(tempId, { + id: tempId, + message, + }) this.commit() + + if (!this.isProcessingPendingMessages) { + this.processPendingMessages() + } } async processPendingMessages() { + logger.debug( + `Convo: processing messages (${this.pendingMessages.size} remaining)`, + {}, + logger.DebugContext.convo, + ) + const pendingMessage = Array.from(this.pendingMessages.values()).shift() /* @@ -343,6 +712,12 @@ export class Convo { } async batchRetryPendingMessages() { + logger.debug( + `Convo: retrying ${this.pendingMessages.size} pending messages`, + {}, + logger.DebugContext.convo, + ) + this.footerItems.delete('pending-retry') this.commit() @@ -393,32 +768,43 @@ export class Convo { } } - 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.trim()) return - - const tempId = nanoid() + async deleteMessage(messageId: string) { + logger.debug('Convo: delete message', {}, logger.DebugContext.convo) - this.pendingMessages.set(tempId, { - id: tempId, - message, - }) + this.deletedMessages.add(messageId) this.commit() - if (!this.isProcessingPendingMessages) { - this.processPendingMessages() + try { + await this.agent.api.chat.bsky.convo.deleteMessageForSelf( + { + convoId: this.convoId, + messageId, + }, + { + encoding: 'application/json', + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + } catch (e) { + this.deletedMessages.delete(messageId) + this.commit() + throw e } } /* * Items in reverse order, since FlatList inverts */ - get items(): ConvoItem[] { + getItems(): ConvoItem[] { const items: ConvoItem[] = [] - // `newMessages` is in insertion order, unshift to reverse - this.newMessages.forEach(m => { + this.headerItems.forEach(item => { + items.push(item) + }) + + this.pastMessages.forEach(m => { if (ChatBskyConvoDefs.isMessageView(m)) { items.unshift({ type: 'message', @@ -436,27 +822,7 @@ export class Convo { } }) - // `newMessages` is in insertion order, unshift to reverse - this.pendingMessages.forEach(m => { - items.unshift({ - type: 'pending-message', - key: m.id, - message: { - ...m.message, - id: nanoid(), - rev: '__fake__', - sentAt: new Date().toISOString(), - sender: this.sender, - }, - nextMessage: null, - }) - }) - - this.footerItems.forEach(item => { - items.unshift(item) - }) - - this.pastMessages.forEach(m => { + this.newMessages.forEach(m => { if (ChatBskyConvoDefs.isMessageView(m)) { items.push({ type: 'message', @@ -474,88 +840,61 @@ export class Convo { } }) - return items.map((item, i) => { - let nextMessage = null - const isMessage = isConvoItemMessage(item) + this.pendingMessages.forEach(m => { + items.push({ + type: 'pending-message', + key: m.id, + message: { + ...m.message, + id: nanoid(), + rev: '__fake__', + sentAt: new Date().toISOString(), + sender: this.sender, + }, + nextMessage: null, + }) + }) + + this.footerItems.forEach(item => { + items.push(item) + }) - if (isMessage) { - if ( - isMessage && - (ChatBskyConvoDefs.isMessageView(item.message) || - ChatBskyConvoDefs.isDeletedMessageView(item.message)) - ) { - const next = items[i - 1] + return items + .filter(item => { + if (isConvoItemMessage(item)) { + return !this.deletedMessages.has(item.message.id) + } + return true + }) + .map((item, i, arr) => { + let nextMessage = null + const isMessage = isConvoItemMessage(item) + if (isMessage) { if ( - isConvoItemMessage(next) && - next && - (ChatBskyConvoDefs.isMessageView(next.message) || - ChatBskyConvoDefs.isDeletedMessageView(next.message)) + isMessage && + (ChatBskyConvoDefs.isMessageView(item.message) || + ChatBskyConvoDefs.isDeletedMessageView(item.message)) ) { - nextMessage = next.message + const next = arr[i + 1] + + if ( + isConvoItemMessage(next) && + next && + (ChatBskyConvoDefs.isMessageView(next.message) || + ChatBskyConvoDefs.isDeletedMessageView(next.message)) + ) { + nextMessage = next.message + } } - } - - return { - ...item, - nextMessage, - } - } - return item - }) - } - - destroy() { - this.status = ConvoStatus.Destroyed - this.commit() - } - - get state(): ConvoState { - switch (this.status) { - case ConvoStatus.Initializing: { - return { - status: ConvoStatus.Initializing, - } - } - case ConvoStatus.Ready: { - return { - status: ConvoStatus.Ready, - items: this.items, - convo: this.convo!, - isFetchingHistory: this.isFetchingHistory, - } - } - case ConvoStatus.Error: { - return { - status: ConvoStatus.Error, - error: this.error, - } - } - case ConvoStatus.Destroyed: { - return { - status: ConvoStatus.Destroyed, - } - } - default: { - return { - status: ConvoStatus.Uninitialized, + return { + ...item, + nextMessage, + } } - } - } - } - - 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) + return item + }) } } diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx index cdc5a4db2..22c4242e2 100644 --- a/src/state/messages/index.tsx +++ b/src/state/messages/index.tsx @@ -1,14 +1,12 @@ -import React, {useContext, useEffect, useMemo, useState} from 'react' +import React, {useContext, useState, useSyncExternalStore} from 'react' import {BskyAgent} from '@atproto-labs/api' +import {useFocusEffect} from '@react-navigation/native' -import {Convo, ConvoParams} from '#/state/messages/convo' +import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' import {useAgent} from '#/state/session' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' -const ChatContext = React.createContext<{ - service: Convo - state: Convo['state'] -} | null>(null) +const ChatContext = React.createContext<ConvoState | null>(null) export function useChat() { const ctx = useContext(ChatContext) @@ -24,7 +22,7 @@ export function ChatProvider({ }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { const {serviceUrl} = useDmServiceUrlStorage() const {getAgent} = useAgent() - const [service] = useState( + const [convo] = useState( () => new Convo({ convoId, @@ -34,21 +32,17 @@ export function ChatProvider({ __tempFromUserDid: getAgent().session?.did!, }), ) - const [state, setState] = useState(service.state) + const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) - useEffect(() => { - service.initialize() - }, [service]) + useFocusEffect( + React.useCallback(() => { + convo.resume() - useEffect(() => { - const update = () => setState(service.state) - service.on('update', update) - return () => { - service.destroy() - } - }, [service]) - - const value = useMemo(() => ({service, state}), [service, state]) + return () => { + convo.background() + } + }, [convo]), + ) - return <ChatContext.Provider value={value}>{children}</ChatContext.Provider> + return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> } |