Clean up WIP, explore suspend/resume

This commit is contained in:
Eric Bailey
2024-05-02 17:04:51 -05:00
parent 10b9de4e94
commit 52dd65668c
4 changed files with 171 additions and 125 deletions
@@ -89,7 +89,9 @@ export function MessagesList() {
}, []) }, [])
const onEndReached = useCallback(() => { const onEndReached = useCallback(() => {
chat.service.fetchMessageHistory() if (chat.status === ConvoStatus.Ready) {
chat.fetchMessageHistory()
}
}, [chat]) }, [chat])
const onInputFocus = useCallback(() => { const onInputFocus = useCallback(() => {
@@ -102,9 +104,11 @@ export function MessagesList() {
const onSendMessage = useCallback( const onSendMessage = useCallback(
(text: string) => { (text: string) => {
chat.service.sendMessage({ if (chat.status === ConvoStatus.Ready) {
text, chat.sendMessage({
}) text,
})
}
}, },
[chat], [chat],
) )
@@ -134,9 +138,7 @@ export function MessagesList() {
contentContainerStyle={a.flex_1}> contentContainerStyle={a.flex_1}>
<FlatList <FlatList
ref={flatListRef} ref={flatListRef}
data={ data={chat.status === ConvoStatus.Ready ? chat.items : undefined}
chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined
}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderItem} renderItem={renderItem}
contentContainerStyle={{paddingHorizontal: 10}} contentContainerStyle={{paddingHorizontal: 10}}
@@ -159,8 +161,7 @@ export function MessagesList() {
ListFooterComponent={ ListFooterComponent={
<MaybeLoader <MaybeLoader
isLoading={ isLoading={
chat.state.status === ConvoStatus.Ready && chat.status === ConvoStatus.Ready && chat.isFetchingHistory
chat.state.isFetchingHistory
} }
/> />
} }
+8 -8
View File
@@ -46,16 +46,16 @@ function Inner() {
const myDid = currentAccount?.did const myDid = currentAccount?.did
const otherProfile = React.useMemo(() => { const otherProfile = React.useMemo(() => {
if (chat.state.status !== ConvoStatus.Ready) return if (chat.status !== ConvoStatus.Ready) return
return chat.state.convo.members.find(m => m.did !== myDid) return chat.convo.members.find(m => m.did !== myDid)
}, [chat.state, myDid]) }, [chat, myDid])
// TODO whenever we have error messages, we should use them in here -hailey // 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 ( return (
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={true} isLoading={true}
isError={chat.state.status === ConvoStatus.Error} isError={chat.status === ConvoStatus.Error}
/> />
) )
} }
@@ -77,7 +77,7 @@ let Header = ({
const {_} = useLingui() const {_} = useLingui()
const {gtTablet} = useBreakpoints() const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {state} = useChat() const chat = useChat()
const onPressBack = useCallback(() => { const onPressBack = useCallback(() => {
if (isWeb) { if (isWeb) {
@@ -129,9 +129,9 @@ let Header = ({
<PreviewableUserAvatar size={32} profile={profile} /> <PreviewableUserAvatar size={32} profile={profile} />
<Text style={[a.text_lg, a.font_bold]}>{profile.displayName}</Text> <Text style={[a.text_lg, a.font_bold]}>{profile.displayName}</Text>
</View> </View>
{state.status === ConvoStatus.Ready && state.convo ? ( {chat.status === ConvoStatus.Ready ? (
<ConvoMenu <ConvoMenu
convo={state.convo} convo={chat.convo}
profile={profile} profile={profile}
onUpdateConvo={onUpdateConvo} onUpdateConvo={onUpdateConvo}
currentScreen="conversation" currentScreen="conversation"
+140 -106
View File
@@ -17,9 +17,10 @@ export type ConvoParams = {
export enum ConvoStatus { export enum ConvoStatus {
Uninitialized = 'uninitialized', Uninitialized = 'uninitialized',
Initializing = 'initializing', Initializing = 'initializing',
Resuming = 'resuming',
Ready = 'ready', Ready = 'ready',
Error = 'error', Error = 'error',
Destroyed = 'destroyed', Suspended = 'suspended',
} }
export type ConvoItem = export type ConvoItem =
@@ -59,25 +60,28 @@ export type ConvoState =
items: ConvoItem[] items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
isFetchingHistory: boolean isFetchingHistory: boolean
deleteMessage: (messageId: string) => 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 status: ConvoStatus.Error
error: any 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( export function isConvoItemMessage(
item: ConvoItem, item: ConvoItem,
@@ -127,97 +131,98 @@ export class Convo {
this.agent = params.agent this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid this.__tempFromUserDid = params.__tempFromUserDid
/*
* Bind methods used by `useSyncExternalStore`
*/
this.subscribe = this.subscribe.bind(this) this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this) this.getSnapshot = this.getSnapshot.bind(this)
} }
private subscribers: (() => void)[] = [] async refreshConvo() {
const response = await this.agent.api.chat.bsky.convo.getConvo(
subscribe(subscriber: () => void) { {
console.log('SUBSCRIBE') convoId: this.convoId,
this.subscribers.push(subscriber) },
this.initialize() {
return () => { headers: {
console.log('UN-SUBSCRIBE') Authorization: this.__tempFromUserDid,
this.subscribers = this.subscribers.filter(s => s !== subscriber) },
} },
)
this.convo = response.data.convo
this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid)
} }
snapshot: ConvoInterface = { async resume() {
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
try { try {
const response = await this.agent.api.chat.bsky.convo.getConvo( if (this.status === ConvoStatus.Uninitialized) {
{ console.log('INITIALIZING')
convoId: this.convoId, this.status = ConvoStatus.Initializing
}, this.generateSnapshot()
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {convo} = response.data
this.convo = convo await this.refreshConvo()
this.sender = this.convo.members.find( this.status = ConvoStatus.Ready
m => m.did === this.__tempFromUserDid, this.generateSnapshot()
)
this.status = ConvoStatus.Ready
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) { } catch (e) {
this.status = ConvoStatus.Error this.status = ConvoStatus.Error
this.error = e this.error = e
} }
} }
async suspend() {
this.status = ConvoStatus.Suspended
this.generateSnapshot()
}
private async pollEvents() { private async pollEvents() {
if (this.status === ConvoStatus.Destroyed) return if (this.status !== ConvoStatus.Ready) return
if (this.pendingEventIngestion) return if (this.pendingEventIngestion) return
console.log('POLL')
setTimeout(async () => { setTimeout(async () => {
this.pendingEventIngestion = this.ingestLatestEvents() this.pendingEventIngestion = this.ingestLatestEvents()
await this.pendingEventIngestion await this.pendingEventIngestion
this.pendingEventIngestion = undefined this.pendingEventIngestion = undefined
this.pollEvents() this.pollEvents()
}, 5e3) }, 1e3)
} }
async fetchMessageHistory() { async fetchMessageHistory() {
if (this.status === ConvoStatus.Destroyed) return if (this.status !== ConvoStatus.Ready) return
// reached end
/*
* If historyCursor is null, we've fetched all history.
*/
if (this.historyCursor === null) return if (this.historyCursor === null) return
/*
* Don't fetch again if a fetch is already in progress
*/
if (this.isFetchingHistory) return if (this.isFetchingHistory) return
this.isFetchingHistory = true this.isFetchingHistory = true
this.commit() this.generateSnapshot()
/* /*
* Delay if paginating while scrolled. * Delay if paginating while scrolled to prevent momentum scrolling from
* * jerking the list around, plus makes it feel a little more human.
* 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) { if (this.pastMessages.size > 0) {
await new Promise(y => setTimeout(y, 500)) await new Promise(y => setTimeout(y, 500))
@@ -256,11 +261,11 @@ export class Convo {
} }
this.isFetchingHistory = false this.isFetchingHistory = false
this.commit() this.generateSnapshot()
} }
async ingestLatestEvents() { async ingestLatestEvents() {
if (this.status === ConvoStatus.Destroyed) return if (this.status === ConvoStatus.Suspended) return
const response = await this.agent.api.chat.bsky.convo.getLog( const response = await this.agent.api.chat.bsky.convo.getLog(
{ {
@@ -327,7 +332,7 @@ export class Convo {
} }
} }
this.commit() this.generateSnapshot()
} }
async processPendingMessages() { async processPendingMessages() {
@@ -374,20 +379,20 @@ export class Convo {
await this.processPendingMessages() await this.processPendingMessages()
this.commit() this.generateSnapshot()
} catch (e) { } catch (e) {
this.footerItems.set('pending-retry', { this.footerItems.set('pending-retry', {
type: 'pending-retry', type: 'pending-retry',
key: 'pending-retry', key: 'pending-retry',
retry: this.batchRetryPendingMessages.bind(this), retry: this.batchRetryPendingMessages.bind(this),
}) })
this.commit() this.generateSnapshot()
} }
} }
async batchRetryPendingMessages() { async batchRetryPendingMessages() {
this.footerItems.delete('pending-retry') this.footerItems.delete('pending-retry')
this.commit() this.generateSnapshot()
try { try {
const messageArray = Array.from(this.pendingMessages.values()) const messageArray = Array.from(this.pendingMessages.values())
@@ -425,19 +430,19 @@ export class Convo {
this.pendingMessages.delete(pendingMessage.id) this.pendingMessages.delete(pendingMessage.id)
} }
this.commit() this.generateSnapshot()
} catch (e) { } catch (e) {
this.footerItems.set('pending-retry', { this.footerItems.set('pending-retry', {
type: 'pending-retry', type: 'pending-retry',
key: 'pending-retry', key: 'pending-retry',
retry: this.batchRetryPendingMessages.bind(this), retry: this.batchRetryPendingMessages.bind(this),
}) })
this.commit() this.generateSnapshot()
} }
} }
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { 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 // Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim()) return if (!message.text.trim()) return
@@ -447,7 +452,7 @@ export class Convo {
id: tempId, id: tempId,
message, message,
}) })
this.commit() this.generateSnapshot()
if (!this.isProcessingPendingMessages) { if (!this.isProcessingPendingMessages) {
this.processPendingMessages() this.processPendingMessages()
@@ -456,7 +461,7 @@ export class Convo {
async deleteMessage(messageId: string) { async deleteMessage(messageId: string) {
this.deletedMessages.add(messageId) this.deletedMessages.add(messageId)
this.commit() this.generateSnapshot()
try { try {
await this.agent.api.chat.bsky.convo.deleteMessageForSelf( await this.agent.api.chat.bsky.convo.deleteMessageForSelf(
@@ -473,7 +478,7 @@ export class Convo {
) )
} catch (e) { } catch (e) {
this.deletedMessages.delete(messageId) this.deletedMessages.delete(messageId)
this.commit() this.generateSnapshot()
throw e throw e
} }
} }
@@ -481,7 +486,7 @@ export class Convo {
/* /*
* Items in reverse order, since FlatList inverts * Items in reverse order, since FlatList inverts
*/ */
get items(): ConvoItem[] { getItems(): ConvoItem[] {
const items: ConvoItem[] = [] const items: ConvoItem[] = []
// `newMessages` is in insertion order, unshift to reverse // `newMessages` is in insertion order, unshift to reverse
@@ -580,55 +585,84 @@ export class Convo {
}) })
} }
destroy() { snapshot: ConvoState = {
this.status = ConvoStatus.Destroyed status: ConvoStatus.Uninitialized,
this.commit()
} }
private commit() { private generateSnapshot() {
switch (this.status) { switch (this.status) {
case ConvoStatus.Initializing: { case ConvoStatus.Initializing: {
this.snapshot.state = { this.snapshot = {
status: ConvoStatus.Initializing, status: ConvoStatus.Initializing,
} }
break break
} }
case ConvoStatus.Ready: { case ConvoStatus.Ready: {
this.snapshot.state = { this.snapshot = {
status: ConvoStatus.Ready, status: this.status,
items: this.items, 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!, convo: this.convo!,
isFetchingHistory: this.isFetchingHistory, isFetchingHistory: this.isFetchingHistory,
} }
break break
} }
case ConvoStatus.Error: { case ConvoStatus.Error: {
this.snapshot.state = { this.snapshot = {
status: ConvoStatus.Error, status: ConvoStatus.Error,
error: this.error, error: this.error,
} }
break break
} }
case ConvoStatus.Destroyed: {
this.snapshot.state = {
status: ConvoStatus.Destroyed,
}
break
}
default: { default: {
this.snapshot.state = { this.snapshot = {
status: ConvoStatus.Uninitialized, status: ConvoStatus.Uninitialized,
} }
break break
} }
} }
this.snapshot = Object.assign({}, this.snapshot) this.emitNewSnapshot()
this.alertSubscribers()
} }
private alertSubscribers() { private emitNewSnapshot() {
this.subscribers.forEach(subscriber => subscriber()) 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
}
} }
+13 -2
View File
@@ -1,11 +1,12 @@
import React, {useContext, useState, useSyncExternalStore} from 'react' import React, {useContext, useState, useSyncExternalStore} from 'react'
import {BskyAgent} from '@atproto-labs/api' 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 {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<ConvoInterface | null>(null) const ChatContext = React.createContext<ConvoState | null>(null)
export function useChat() { export function useChat() {
const ctx = useContext(ChatContext) const ctx = useContext(ChatContext)
@@ -33,5 +34,15 @@ export function ChatProvider({
) )
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
useFocusEffect(
React.useCallback(() => {
convo.resume()
return () => {
convo.suspend()
}
}, [convo]),
)
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
} }