diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index f6f786ee69..62247e7e3f 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -89,7 +89,9 @@ export function MessagesList() { }, []) const onEndReached = useCallback(() => { - chat.service.fetchMessageHistory() + if (chat.status === ConvoStatus.Ready) { + chat.fetchMessageHistory() + } }, [chat]) const onInputFocus = useCallback(() => { @@ -102,9 +104,11 @@ export function MessagesList() { const onSendMessage = useCallback( (text: string) => { - chat.service.sendMessage({ - text, - }) + if (chat.status === ConvoStatus.Ready) { + chat.sendMessage({ + text, + }) + } }, [chat], ) @@ -134,9 +138,7 @@ export function MessagesList() { contentContainerStyle={a.flex_1}> } diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 41075b5d50..430a99d4e6 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -46,16 +46,16 @@ function Inner() { const myDid = currentAccount?.did const otherProfile = React.useMemo(() => { - if (chat.state.status !== ConvoStatus.Ready) return - return chat.state.convo.members.find(m => m.did !== myDid) - }, [chat.state, myDid]) + if (chat.status !== ConvoStatus.Ready) return + return chat.convo.members.find(m => m.did !== myDid) + }, [chat, myDid]) // TODO whenever we have error messages, we should use them in here -hailey - if (chat.state.status !== ConvoStatus.Ready || !otherProfile) { + if (chat.status !== ConvoStatus.Ready || !otherProfile) { return ( ) } @@ -77,7 +77,7 @@ let Header = ({ const {_} = useLingui() const {gtTablet} = useBreakpoints() const navigation = useNavigation() - const {state} = useChat() + const chat = useChat() const onPressBack = useCallback(() => { if (isWeb) { @@ -129,9 +129,9 @@ let Header = ({ {profile.displayName} - {state.status === ConvoStatus.Ready && state.convo ? ( + {chat.status === ConvoStatus.Ready ? ( void + sendMessage: ( + message: ChatBskyConvoSendMessage.InputSchema['message'], + ) => void + fetchMessageHistory: () => void + } + | { + status: ConvoStatus.Suspended + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + isFetchingHistory: boolean + } + | { + status: ConvoStatus.Resuming + items: ConvoItem[] + convo: ChatBskyConvoDefs.ConvoView + isFetchingHistory: boolean } | { status: ConvoStatus.Error error: any } - | { - status: ConvoStatus.Destroyed - } - -export type ConvoInterface = { - state: ConvoState - service: { - deleteMessage: (messageId: string) => void - sendMessage: ( - message: ChatBskyConvoSendMessage.InputSchema['message'], - ) => void - fetchMessageHistory: () => void - } -} export function isConvoItemMessage( item: ConvoItem, @@ -127,97 +131,98 @@ export class Convo { this.agent = params.agent this.__tempFromUserDid = params.__tempFromUserDid + /* + * Bind methods used by `useSyncExternalStore` + */ this.subscribe = this.subscribe.bind(this) this.getSnapshot = this.getSnapshot.bind(this) } - private subscribers: (() => void)[] = [] - - subscribe(subscriber: () => void) { - console.log('SUBSCRIBE') - this.subscribers.push(subscriber) - this.initialize() - return () => { - console.log('UN-SUBSCRIBE') - this.subscribers = this.subscribers.filter(s => s !== subscriber) - } + async refreshConvo() { + const response = await this.agent.api.chat.bsky.convo.getConvo( + { + convoId: this.convoId, + }, + { + headers: { + Authorization: this.__tempFromUserDid, + }, + }, + ) + this.convo = response.data.convo + this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid) } - snapshot: ConvoInterface = { - state: { - status: ConvoStatus.Uninitialized, - }, - service: { - deleteMessage: this.deleteMessage.bind(this), - sendMessage: this.sendMessage.bind(this), - fetchMessageHistory: this.fetchMessageHistory.bind(this), - }, - } - - getSnapshot(): ConvoInterface { - return this.snapshot - } - - async initialize() { - if (this.status !== ConvoStatus.Uninitialized) return - this.status = ConvoStatus.Initializing - + async resume() { try { - const response = await this.agent.api.chat.bsky.convo.getConvo( - { - convoId: this.convoId, - }, - { - headers: { - Authorization: this.__tempFromUserDid, - }, - }, - ) - const {convo} = response.data + if (this.status === ConvoStatus.Uninitialized) { + console.log('INITIALIZING') + this.status = ConvoStatus.Initializing + this.generateSnapshot() - this.convo = convo - this.sender = this.convo.members.find( - m => m.did === this.__tempFromUserDid, - ) - this.status = ConvoStatus.Ready + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.generateSnapshot() - this.commit() + await this.fetchMessageHistory() - await this.fetchMessageHistory() + this.pollEvents() + } else if (this.status === ConvoStatus.Suspended) { + console.log('RESUMING') + this.status = ConvoStatus.Resuming + this.generateSnapshot() - this.pollEvents() + await this.refreshConvo() + this.status = ConvoStatus.Ready + this.generateSnapshot() + + await this.fetchMessageHistory() + + this.pollEvents() + } } catch (e) { this.status = ConvoStatus.Error this.error = e } } + async suspend() { + this.status = ConvoStatus.Suspended + this.generateSnapshot() + } + private async pollEvents() { - if (this.status === ConvoStatus.Destroyed) return + if (this.status !== ConvoStatus.Ready) return if (this.pendingEventIngestion) return + + console.log('POLL') setTimeout(async () => { this.pendingEventIngestion = this.ingestLatestEvents() await this.pendingEventIngestion this.pendingEventIngestion = undefined this.pollEvents() - }, 5e3) + }, 1e3) } async fetchMessageHistory() { - if (this.status === ConvoStatus.Destroyed) return - // reached end + if (this.status !== ConvoStatus.Ready) return + + /* + * If historyCursor is null, we've fetched all history. + */ if (this.historyCursor === null) return + + /* + * Don't fetch again if a fetch is already in progress + */ if (this.isFetchingHistory) return this.isFetchingHistory = true - this.commit() + this.generateSnapshot() /* - * 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. + * 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)) @@ -256,11 +261,11 @@ export class Convo { } this.isFetchingHistory = false - this.commit() + this.generateSnapshot() } async ingestLatestEvents() { - if (this.status === ConvoStatus.Destroyed) return + if (this.status === ConvoStatus.Suspended) return const response = await this.agent.api.chat.bsky.convo.getLog( { @@ -327,7 +332,7 @@ export class Convo { } } - this.commit() + this.generateSnapshot() } async processPendingMessages() { @@ -374,20 +379,20 @@ export class Convo { await this.processPendingMessages() - this.commit() + this.generateSnapshot() } catch (e) { this.footerItems.set('pending-retry', { type: 'pending-retry', key: 'pending-retry', retry: this.batchRetryPendingMessages.bind(this), }) - this.commit() + this.generateSnapshot() } } async batchRetryPendingMessages() { this.footerItems.delete('pending-retry') - this.commit() + this.generateSnapshot() try { const messageArray = Array.from(this.pendingMessages.values()) @@ -425,19 +430,19 @@ export class Convo { this.pendingMessages.delete(pendingMessage.id) } - this.commit() + this.generateSnapshot() } catch (e) { this.footerItems.set('pending-retry', { type: 'pending-retry', key: 'pending-retry', retry: this.batchRetryPendingMessages.bind(this), }) - this.commit() + this.generateSnapshot() } } async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { - if (this.status === ConvoStatus.Destroyed) return + if (this.status === ConvoStatus.Suspended) return // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim()) return @@ -447,7 +452,7 @@ export class Convo { id: tempId, message, }) - this.commit() + this.generateSnapshot() if (!this.isProcessingPendingMessages) { this.processPendingMessages() @@ -456,7 +461,7 @@ export class Convo { async deleteMessage(messageId: string) { this.deletedMessages.add(messageId) - this.commit() + this.generateSnapshot() try { await this.agent.api.chat.bsky.convo.deleteMessageForSelf( @@ -473,7 +478,7 @@ export class Convo { ) } catch (e) { this.deletedMessages.delete(messageId) - this.commit() + this.generateSnapshot() throw e } } @@ -481,7 +486,7 @@ export class Convo { /* * Items in reverse order, since FlatList inverts */ - get items(): ConvoItem[] { + getItems(): ConvoItem[] { const items: ConvoItem[] = [] // `newMessages` is in insertion order, unshift to reverse @@ -580,55 +585,84 @@ export class Convo { }) } - destroy() { - this.status = ConvoStatus.Destroyed - this.commit() + snapshot: ConvoState = { + status: ConvoStatus.Uninitialized, } - private commit() { + private generateSnapshot() { switch (this.status) { case ConvoStatus.Initializing: { - this.snapshot.state = { + this.snapshot = { status: ConvoStatus.Initializing, } break } case ConvoStatus.Ready: { - this.snapshot.state = { - status: ConvoStatus.Ready, - items: this.items, + this.snapshot = { + status: this.status, + items: this.getItems(), + convo: this.convo!, + isFetchingHistory: this.isFetchingHistory, + deleteMessage: this.deleteMessage.bind(this), + sendMessage: this.sendMessage.bind(this), + fetchMessageHistory: this.fetchMessageHistory.bind(this), + } + break + } + case ConvoStatus.Suspended: { + this.snapshot = { + status: this.status, + items: this.getItems(), + convo: this.convo!, + isFetchingHistory: this.isFetchingHistory, + } + break + } + case ConvoStatus.Resuming: { + this.snapshot = { + status: this.status, + items: this.getItems(), convo: this.convo!, isFetchingHistory: this.isFetchingHistory, } break } case ConvoStatus.Error: { - this.snapshot.state = { + this.snapshot = { status: ConvoStatus.Error, error: this.error, } break } - case ConvoStatus.Destroyed: { - this.snapshot.state = { - status: ConvoStatus.Destroyed, - } - break - } default: { - this.snapshot.state = { + this.snapshot = { status: ConvoStatus.Uninitialized, } break } } - this.snapshot = Object.assign({}, this.snapshot) - - this.alertSubscribers() + this.emitNewSnapshot() } - private alertSubscribers() { + private emitNewSnapshot() { this.subscribers.forEach(subscriber => subscriber()) } + + private subscribers: (() => void)[] = [] + + subscribe(subscriber: () => void) { + console.log('SUBSCRIBED') + this.subscribers.push(subscriber) + this.resume() + return () => { + console.log('UN-SUBSCRIBED') + this.suspend() + this.subscribers = this.subscribers.filter(s => s !== subscriber) + } + } + + getSnapshot(): ConvoState { + return this.snapshot + } } diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx index 12f25484a4..2570962048 100644 --- a/src/state/messages/index.tsx +++ b/src/state/messages/index.tsx @@ -1,11 +1,12 @@ import React, {useContext, useState, useSyncExternalStore} from 'react' import {BskyAgent} from '@atproto-labs/api' +import {useFocusEffect} from '@react-navigation/native' -import {Convo, ConvoInterface, 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(null) +const ChatContext = React.createContext(null) export function useChat() { const ctx = useContext(ChatContext) @@ -33,5 +34,15 @@ export function ChatProvider({ ) const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) + useFocusEffect( + React.useCallback(() => { + convo.resume() + + return () => { + convo.suspend() + } + }, [convo]), + ) + return {children} }