[🐴] State transitions (#3880)

* Handle init/resume/suspend/background and polling

* Add debug and temp guard

* Make state transitions sync

* Make init sync also

* Checkpoint: confusing but working state machine

* Reducer-esque

* Remove poll events

* Guard fetchConvo

(cherry picked from commit 8385579d31500bb4bfb60afeecdc1eb3ddd7e747)

* Clean up polling, make sync

(cherry picked from commit 7f75cd04c3bf81c94662785748698640a84bef51)

* Update history handling

(cherry picked from commit b82b552ba4040adf7ead2377541132a386964ff8)

* Check for screen focus in app state listener

* Get rid of ad-hoc status checks
This commit is contained in:
Eric Bailey 2024-05-07 17:54:52 -05:00 committed by GitHub
parent 87cb4c105e
commit f78126e01a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 500 additions and 218 deletions

View file

@ -18,10 +18,10 @@ export function MessageListError({
const {_} = useLingui() const {_} = useLingui()
const message = React.useMemo(() => { const message = React.useMemo(() => {
return { return {
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), [ConvoItemError.Network]: _(
[ConvoItemError.ResumeFailed]: _(
msg`There was an issue connecting to the chat.`, msg`There was an issue connecting to the chat.`,
), ),
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[ConvoItemError.PollFailed]: _( [ConvoItemError.PollFailed]: _(
msg`This chat was disconnected due to a network error.`, msg`This chat was disconnected due to a network error.`,
), ),

View file

@ -18,6 +18,7 @@ import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views' import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu' import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {ListMaybePlaceholder} from '#/components/Lists' import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@ -51,8 +52,21 @@ function Inner() {
} }
if (chat.status === ConvoStatus.Error) { if (chat.status === ConvoStatus.Error) {
// TODO error // TODO
return null return (
<View>
<CenteredView style={{flex: 1}} sideBorders>
<Text>Something went wrong</Text>
<Button
label="Retry"
onPress={() => {
chat.error.retry()
}}>
<ButtonText>Retry</ButtonText>
</Button>
</CenteredView>
</View>
)
} }
/* /*

View file

@ -2,6 +2,7 @@ import {AppBskyActorDefs} from '@atproto/api'
import { import {
BskyAgent, BskyAgent,
ChatBskyConvoDefs, ChatBskyConvoDefs,
ChatBskyConvoGetLog,
ChatBskyConvoSendMessage, ChatBskyConvoSendMessage,
} from '@atproto-labs/api' } from '@atproto-labs/api'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
@ -18,7 +19,6 @@ 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',
Backgrounded = 'backgrounded', Backgrounded = 'backgrounded',
@ -27,14 +27,50 @@ export enum ConvoStatus {
export enum ConvoItemError { export enum ConvoItemError {
HistoryFailed = 'historyFailed', HistoryFailed = 'historyFailed',
ResumeFailed = 'resumeFailed',
PollFailed = 'pollFailed', PollFailed = 'pollFailed',
Network = 'network',
} }
export enum ConvoError { export enum ConvoErrorCode {
InitFailed = 'initFailed', InitFailed = 'initFailed',
} }
export type ConvoError = {
code: ConvoErrorCode
exception?: Error
retry: () => void
}
export enum ConvoDispatchEvent {
Init = 'init',
Ready = 'ready',
Resume = 'resume',
Background = 'background',
Suspend = 'suspend',
Error = 'error',
}
export type ConvoDispatch =
| {
event: ConvoDispatchEvent.Init
}
| {
event: ConvoDispatchEvent.Ready
}
| {
event: ConvoDispatchEvent.Resume
}
| {
event: ConvoDispatchEvent.Background
}
| {
event: ConvoDispatchEvent.Suspend
}
| {
event: ConvoDispatchEvent.Error
payload: ConvoError
}
export type ConvoItem = export type ConvoItem =
| { | {
type: 'message' | 'pending-message' type: 'message' | 'pending-message'
@ -133,20 +169,6 @@ export type ConvoState =
) => Promise<void> ) => Promise<void>
fetchMessageHistory: () => 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.Error status: ConvoStatus.Error
items: [] items: []
@ -160,9 +182,12 @@ export type ConvoState =
fetchMessageHistory: undefined fetchMessageHistory: undefined
} }
const ACTIVE_POLL_INTERVAL = 2e3 const ACTIVE_POLL_INTERVAL = 1e3
const BACKGROUND_POLL_INTERVAL = 10e3 const BACKGROUND_POLL_INTERVAL = 10e3
// TODO temporary
let DEBUG_ACTIVE_CHAT: string | undefined
export function isConvoItemMessage( export function isConvoItemMessage(
item: ConvoItem, item: ConvoItem,
): item is ConvoItem & {type: 'message'} { ): item is ConvoItem & {type: 'message'} {
@ -175,14 +200,16 @@ export function isConvoItemMessage(
} }
export class Convo { export class Convo {
private id: string
private agent: BskyAgent private agent: BskyAgent
private __tempFromUserDid: string private __tempFromUserDid: string
private pollInterval = ACTIVE_POLL_INTERVAL
private status: ConvoStatus = ConvoStatus.Uninitialized private status: ConvoStatus = ConvoStatus.Uninitialized
private pollInterval = ACTIVE_POLL_INTERVAL
private error: private error:
| { | {
code: ConvoError code: ConvoErrorCode
exception?: Error exception?: Error
retry: () => void retry: () => void
} }
@ -190,7 +217,6 @@ export class Convo {
private historyCursor: string | undefined | null = undefined private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false private isFetchingHistory = false
private eventsCursor: string | undefined = undefined private eventsCursor: string | undefined = undefined
private pollingFailure = false
private pastMessages: Map< private pastMessages: Map<
string, string,
@ -208,8 +234,9 @@ export class Convo {
private footerItems: Map<string, ConvoItem> = new Map() private footerItems: Map<string, ConvoItem> = new Map()
private headerItems: Map<string, ConvoItem> = new Map() private headerItems: Map<string, ConvoItem> = new Map()
private pendingEventIngestion: Promise<void> | undefined
private isProcessingPendingMessages = false private isProcessingPendingMessages = false
private pendingPoll: Promise<void> | undefined
private nextPoll: NodeJS.Timeout | undefined
convoId: string convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined convo: ChatBskyConvoDefs.ConvoView | undefined
@ -218,6 +245,7 @@ export class Convo {
snapshot: ConvoState | undefined snapshot: ConvoState | undefined
constructor(params: ConvoParams) { constructor(params: ConvoParams) {
this.id = nanoid(3)
this.convoId = params.convoId this.convoId = params.convoId
this.agent = params.agent this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid this.__tempFromUserDid = params.__tempFromUserDid
@ -227,6 +255,14 @@ export class Convo {
this.sendMessage = this.sendMessage.bind(this) this.sendMessage = this.sendMessage.bind(this)
this.deleteMessage = this.deleteMessage.bind(this) this.deleteMessage = this.deleteMessage.bind(this)
this.fetchMessageHistory = this.fetchMessageHistory.bind(this) this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
if (DEBUG_ACTIVE_CHAT) {
logger.error(`Convo: another chat was already active`, {
convoId: this.convoId,
})
} else {
DEBUG_ACTIVE_CHAT = this.convoId
}
} }
private commit() { private commit() {
@ -271,7 +307,6 @@ export class Convo {
} }
case ConvoStatus.Suspended: case ConvoStatus.Suspended:
case ConvoStatus.Backgrounded: case ConvoStatus.Backgrounded:
case ConvoStatus.Resuming:
case ConvoStatus.Ready: { case ConvoStatus.Ready: {
return { return {
status: this.status, status: this.status,
@ -317,98 +352,259 @@ export class Convo {
} }
} }
async init() { dispatch(action: ConvoDispatch) {
logger.debug('Convo: init', {}, logger.DebugContext.convo) const prevStatus = this.status
if ( switch (this.status) {
this.status === ConvoStatus.Uninitialized || case ConvoStatus.Uninitialized: {
this.status === ConvoStatus.Error switch (action.event) {
) { case ConvoDispatchEvent.Init: {
try {
this.status = ConvoStatus.Initializing this.status = ConvoStatus.Initializing
this.commit() this.setup()
break
await this.refreshConvo() }
}
break
}
case ConvoStatus.Initializing: {
switch (action.event) {
case ConvoDispatchEvent.Ready: {
this.status = ConvoStatus.Ready 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 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.pollInterval = ACTIVE_POLL_INTERVAL
this.pollEvents() this.fetchMessageHistory().then(() => {
} catch (e) { this.restartPoll()
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()
},
}) })
break
this.status = fromStatus
this.commit()
} }
} else { case ConvoDispatchEvent.Background: {
logger.warn(`Convo: cannot resume from ${this.status}`)
}
}
async background() {
logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo)
this.status = ConvoStatus.Backgrounded this.status = ConvoStatus.Backgrounded
this.pollInterval = BACKGROUND_POLL_INTERVAL this.pollInterval = BACKGROUND_POLL_INTERVAL
this.commit() this.fetchMessageHistory().then(() => {
this.restartPoll()
})
break
} }
case ConvoDispatchEvent.Suspend: {
async suspend() {
logger.debug('Convo: suspended', {}, logger.DebugContext.convo)
this.status = ConvoStatus.Suspended this.status = ConvoStatus.Suspended
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
break
}
}
break
}
case ConvoStatus.Ready: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
this.refreshConvo()
this.restartPoll()
break
}
case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded
this.pollInterval = BACKGROUND_POLL_INTERVAL
this.restartPoll()
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
this.cancelNextPoll()
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
this.cancelNextPoll()
break
}
}
break
}
case ConvoStatus.Backgrounded: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.refreshConvo()
// TODO truncate history if needed
this.restartPoll()
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
this.cancelNextPoll()
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
this.cancelNextPoll()
break
}
}
break
}
case ConvoStatus.Suspended: {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.refreshConvo()
// TODO truncate history if needed
this.restartPoll()
break
}
case ConvoDispatchEvent.Resume: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.refreshConvo()
this.restartPoll()
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
break
}
}
break
}
case ConvoStatus.Error: {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.reset()
break
}
case ConvoDispatchEvent.Resume: {
this.reset()
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
break
}
}
break
}
default:
break
}
logger.debug(
`Convo: dispatch '${action.event}'`,
{
id: this.id,
prev: prevStatus,
next: this.status,
},
logger.DebugContext.convo,
)
this.commit() this.commit()
} }
async refreshConvo() { private reset() {
this.convo = undefined
this.sender = undefined
this.recipients = undefined
this.snapshot = undefined
this.status = ConvoStatus.Uninitialized
this.error = undefined
this.historyCursor = undefined
this.eventsCursor = undefined
this.pastMessages = new Map()
this.newMessages = new Map()
this.pendingMessages = new Map()
this.deletedMessages = new Set()
this.footerItems = new Map()
this.headerItems = new Map()
this.dispatch({event: ConvoDispatchEvent.Init})
}
private async setup() {
try {
const {convo, sender, recipients} = await this.fetchConvo()
this.convo = convo
this.sender = sender
this.recipients = recipients
/*
* Some validation prior to `Ready` status
*/
if (!this.convo) {
throw new Error('Convo: could not find convo')
}
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')
}
// await new Promise(y => setTimeout(y, 2000))
// throw new Error('UNCOMMENT TO TEST INIT FAILURE')
this.dispatch({event: ConvoDispatchEvent.Ready})
} catch (e: any) {
logger.error('Convo: setup() failed')
this.dispatch({
event: ConvoDispatchEvent.Error,
payload: {
exception: e,
code: ConvoErrorCode.InitFailed,
retry: () => {
this.reset()
},
},
})
}
}
init() {
this.dispatch({event: ConvoDispatchEvent.Init})
}
resume() {
this.dispatch({event: ConvoDispatchEvent.Resume})
}
background() {
this.dispatch({event: ConvoDispatchEvent.Background})
}
suspend() {
this.dispatch({event: ConvoDispatchEvent.Suspend})
DEBUG_ACTIVE_CHAT = undefined
}
private pendingFetchConvo:
| Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: AppBskyActorDefs.ProfileViewBasic | undefined
recipients: AppBskyActorDefs.ProfileViewBasic[]
}>
| undefined
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
this.pendingFetchConvo = new Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: AppBskyActorDefs.ProfileViewBasic | undefined
recipients: AppBskyActorDefs.ProfileViewBasic[]
}>(async (resolve, reject) => {
try {
const response = await this.agent.api.chat.bsky.convo.getConvo( const response = await this.agent.api.chat.bsky.convo.getConvo(
{ {
convoId: this.convoId, convoId: this.convoId,
@ -419,20 +615,46 @@ export class Convo {
}, },
}, },
) )
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,
)
/* const convo = response.data.convo
* Prevent invalid states
*/ resolve({
if (!this.sender) { convo,
throw new Error('Convo: could not find sender in convo') sender: convo.members.find(m => m.did === this.__tempFromUserDid),
recipients: convo.members.filter(
m => m.did !== this.__tempFromUserDid,
),
})
} catch (e) {
reject(e)
} finally {
this.pendingFetchConvo = undefined
} }
if (!this.recipients) { })
throw new Error('Convo: could not find recipients in convo')
return this.pendingFetchConvo
}
async refreshConvo() {
try {
const {convo, sender, recipients} = await this.fetchConvo()
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
this.convo = convo || this.convo
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
} catch (e: any) {
logger.error(`Convo: failed to refresh convo`)
this.footerItems.set(ConvoItemError.Network, {
type: 'error-recoverable',
key: ConvoItemError.Network,
code: ConvoItemError.Network,
retry: () => {
this.footerItems.delete(ConvoItemError.Network)
this.resume()
},
})
this.commit()
} }
} }
@ -517,28 +739,59 @@ export class Convo {
} }
} }
private async pollEvents() { private restartPoll() {
if ( this.cancelNextPoll()
this.status === ConvoStatus.Ready || this.pollLatestEvents()
this.status === ConvoStatus.Backgrounded }
) {
if (this.pendingEventIngestion) return
private cancelNextPoll() {
if (this.nextPoll) clearTimeout(this.nextPoll)
}
private pollLatestEvents() {
/* /*
* Represents a failed state, which is retryable. * Uncomment to view poll events
*/ */
if (this.pollingFailure) return logger.debug('Convo: poll events', {id: this.id}, logger.DebugContext.convo)
setTimeout(async () => { try {
this.pendingEventIngestion = this.ingestLatestEvents() this.fetchLatestEvents().then(({events}) => {
await this.pendingEventIngestion this.applyLatestEvents(events)
this.pendingEventIngestion = undefined })
this.pollEvents() this.nextPoll = setTimeout(() => {
this.pollLatestEvents()
}, this.pollInterval) }, this.pollInterval)
} catch (e: any) {
logger.error('Convo: poll events failed')
this.cancelNextPoll()
this.footerItems.set(ConvoItemError.PollFailed, {
type: 'error-recoverable',
key: ConvoItemError.PollFailed,
code: ConvoItemError.PollFailed,
retry: () => {
this.footerItems.delete(ConvoItemError.PollFailed)
this.commit()
this.pollLatestEvents()
},
})
this.commit()
} }
} }
async ingestLatestEvents() { private pendingFetchLatestEvents:
| Promise<{
events: ChatBskyConvoGetLog.OutputSchema['logs']
}>
| undefined
async fetchLatestEvents() {
if (this.pendingFetchLatestEvents) return this.pendingFetchLatestEvents
this.pendingFetchLatestEvents = new Promise<{
events: ChatBskyConvoGetLog.OutputSchema['logs']
}>(async (resolve, reject) => {
try { try {
// throw new Error('UNCOMMENT TO TEST POLL FAILURE') // throw new Error('UNCOMMENT TO TEST POLL FAILURE')
const response = await this.agent.api.chat.bsky.convo.getLog( const response = await this.agent.api.chat.bsky.convo.getLog(
@ -552,57 +805,68 @@ export class Convo {
}, },
) )
const {logs} = response.data const {logs} = response.data
resolve({events: logs})
} catch (e) {
reject(e)
} finally {
this.pendingFetchLatestEvents = undefined
}
})
return this.pendingFetchLatestEvents
}
private applyLatestEvents(events: ChatBskyConvoGetLog.OutputSchema['logs']) {
let needsCommit = false let needsCommit = false
for (const log of logs) { for (const ev of events) {
/* /*
* If there's a rev, we should handle it. If there's not a rev, we don't * If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is. * know what it is.
*/ */
if (typeof log.rev === 'string') { if (typeof ev.rev === 'string') {
/* /*
* We only care about new events * We only care about new events
*/ */
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { if (ev.rev > (this.eventsCursor = this.eventsCursor || ev.rev)) {
/* /*
* Update rev regardless of if it's a log type we care about or not * Update rev regardless of if it's a ev type we care about or not
*/ */
this.eventsCursor = log.rev this.eventsCursor = ev.rev
/* /*
* This is VERY important. We don't want to insert any messages from * This is VERY important. We don't want to insert any messages from
* your other chats. * your other chats.
*/ */
if (log.convoId !== this.convoId) continue if (ev.convoId !== this.convoId) continue
if ( if (
ChatBskyConvoDefs.isLogCreateMessage(log) && ChatBskyConvoDefs.isLogCreateMessage(ev) &&
ChatBskyConvoDefs.isMessageView(log.message) ChatBskyConvoDefs.isMessageView(ev.message)
) { ) {
if (this.newMessages.has(log.message.id)) { if (this.newMessages.has(ev.message.id)) {
// Trust the log as the source of truth on ordering // Trust the ev as the source of truth on ordering
this.newMessages.delete(log.message.id) this.newMessages.delete(ev.message.id)
} }
this.newMessages.set(log.message.id, log.message) this.newMessages.set(ev.message.id, ev.message)
needsCommit = true needsCommit = true
} else if ( } else if (
ChatBskyConvoDefs.isLogDeleteMessage(log) && ChatBskyConvoDefs.isLogDeleteMessage(ev) &&
ChatBskyConvoDefs.isDeletedMessageView(log.message) ChatBskyConvoDefs.isDeletedMessageView(ev.message)
) { ) {
/* /*
* Update if we have this in state. If we don't, don't worry about it. * Update if we have this in state. If we don't, don't worry about it.
*/ */
if (this.pastMessages.has(log.message.id)) { if (this.pastMessages.has(ev.message.id)) {
/* /*
* For now, we remove deleted messages from the thread, if we receive one. * For now, we remove deleted messages from the thread, if we receive one.
* *
* To support them, it'd look something like this: * To support them, it'd look something like this:
* this.pastMessages.set(log.message.id, log.message) * this.pastMessages.set(ev.message.id, ev.message)
*/ */
this.pastMessages.delete(log.message.id) this.pastMessages.delete(ev.message.id)
this.newMessages.delete(log.message.id) this.newMessages.delete(ev.message.id)
this.deletedMessages.delete(log.message.id) this.deletedMessages.delete(ev.message.id)
needsCommit = true needsCommit = true
} }
} }
@ -613,22 +877,6 @@ export class Convo {
if (needsCommit) { if (needsCommit) {
this.commit() 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']) { async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {

View file

@ -1,6 +1,7 @@
import React, {useContext, useState, useSyncExternalStore} from 'react' import React, {useContext, useState, useSyncExternalStore} from 'react'
import {AppState} from 'react-native'
import {BskyAgent} from '@atproto-labs/api' import {BskyAgent} from '@atproto-labs/api'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@ -20,6 +21,7 @@ export function ChatProvider({
children, children,
convoId, convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const isScreenFocused = useIsFocused()
const {serviceUrl} = useDmServiceUrlStorage() const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const [convo] = useState( const [convo] = useState(
@ -44,5 +46,23 @@ export function ChatProvider({
}, [convo]), }, [convo]),
) )
React.useEffect(() => {
const handleAppStateChange = (nextAppState: string) => {
if (isScreenFocused) {
if (nextAppState === 'active') {
convo.resume()
} else {
convo.background()
}
}
}
const sub = AppState.addEventListener('change', handleAppStateChange)
return () => {
sub.remove()
}
}, [convo, isScreenFocused])
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
} }