diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx index 82ca48e8..523788d4 100644 --- a/src/screens/Messages/Conversation/MessageListError.tsx +++ b/src/screens/Messages/Conversation/MessageListError.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {ConvoError, ConvoItem} from '#/state/messages/convo' +import {ConvoItem, ConvoItemError} from '#/state/messages/convo' import {atoms as a, useTheme} from '#/alf' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {InlineLinkText} from '#/components/Link' @@ -18,7 +18,13 @@ export function MessageListError({ const {_} = useLingui() const message = React.useMemo(() => { return { - [ConvoError.HistoryFailed]: _(msg`Failed to load past messages.`), + [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), + [ConvoItemError.ResumeFailed]: _( + msg`There was an issue connecting to the chat.`, + ), + [ConvoItemError.PollFailed]: _( + msg`This chat was disconnected due to a network error.`, + ), }[item.code] }, [_, item.code]) diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 7068097f..1dc26d6c 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -229,7 +229,7 @@ export function MessagesList() { + } /> diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index db07ed2e..11044c21 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -14,7 +14,6 @@ import {BACK_HITSLOP} from 'lib/constants' import {isWeb} from 'platform/detection' import {ChatProvider, useChat} from 'state/messages' import {ConvoStatus} from 'state/messages/convo' -import {useSession} from 'state/session' import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' import {CenteredView} from 'view/com/util/Views' import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' @@ -43,28 +42,27 @@ export function MessagesConversationScreen({route}: Props) { function Inner() { const chat = useChat() - const {currentAccount} = useSession() - const myDid = currentAccount?.did - const otherProfile = React.useMemo(() => { - 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.status !== ConvoStatus.Ready || !otherProfile) { - return ( - - ) + if ( + chat.status === ConvoStatus.Uninitialized || + chat.status === ConvoStatus.Initializing + ) { + return } + if (chat.status === ConvoStatus.Error) { + // TODO error + return null + } + + /* + * Any other chat states (atm) are "ready" states + */ + return ( -
+
diff --git a/src/state/messages/__tests__/convo.test.ts b/src/state/messages/__tests__/convo.test.ts index b0dab532..44fe16fe 100644 --- a/src/state/messages/__tests__/convo.test.ts +++ b/src/state/messages/__tests__/convo.test.ts @@ -1,15 +1,19 @@ 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(`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(`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(`read states`, () => { diff --git a/src/state/messages/convo.ts b/src/state/messages/convo.ts index cf15550d..81ab94f4 100644 --- a/src/state/messages/convo.ts +++ b/src/state/messages/convo.ts @@ -25,8 +25,14 @@ export enum ConvoStatus { Suspended = 'suspended', } -export enum ConvoError { +export enum ConvoItemError { HistoryFailed = 'historyFailed', + ResumeFailed = 'resumeFailed', + PollFailed = 'pollFailed', +} + +export enum ConvoError { + InitFailed = 'initFailed', } export type ConvoItem = @@ -56,14 +62,9 @@ export type ConvoItem = | { type: 'error-recoverable' key: string - code: ConvoError + code: ConvoItemError retry: () => void } - | { - type: 'error-fatal' - code: ConvoError - key: string - } export type ConvoState = | { @@ -71,6 +72,8 @@ export type ConvoState = items: [] convo: undefined error: undefined + sender: undefined + recipients: undefined isFetchingHistory: false deleteMessage: undefined sendMessage: undefined @@ -81,6 +84,8 @@ export type ConvoState = items: [] convo: undefined error: undefined + sender: undefined + recipients: undefined isFetchingHistory: boolean deleteMessage: undefined sendMessage: undefined @@ -91,6 +96,8 @@ export type ConvoState = items: ConvoItem[] convo: ChatBskyConvoDefs.ConvoView error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean deleteMessage: (messageId: string) => Promise sendMessage: ( @@ -103,6 +110,8 @@ export type ConvoState = items: ConvoItem[] convo: ChatBskyConvoDefs.ConvoView error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean deleteMessage: (messageId: string) => Promise sendMessage: ( @@ -115,6 +124,8 @@ export type ConvoState = items: ConvoItem[] convo: ChatBskyConvoDefs.ConvoView error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean deleteMessage: (messageId: string) => Promise sendMessage: ( @@ -127,6 +138,8 @@ export type ConvoState = items: ConvoItem[] convo: ChatBskyConvoDefs.ConvoView error: undefined + sender: AppBskyActorDefs.ProfileViewBasic + recipients: AppBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean deleteMessage: (messageId: string) => Promise sendMessage: ( @@ -139,6 +152,8 @@ export type ConvoState = items: [] convo: undefined error: any + sender: undefined + recipients: undefined isFetchingHistory: false deleteMessage: undefined sendMessage: undefined @@ -165,10 +180,17 @@ export class Convo { 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 + private pollingFailure = false private pastMessages: Map< string, @@ -192,6 +214,7 @@ export class Convo { convoId: string convo: ChatBskyConvoDefs.ConvoView | undefined sender: AppBskyActorDefs.ProfileViewBasic | undefined + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined snapshot: ConvoState | undefined constructor(params: ConvoParams) { @@ -226,7 +249,7 @@ export class Convo { getSnapshot(): ConvoState { if (!this.snapshot) this.snapshot = this.generateSnapshot() - logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) + // logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) return this.snapshot } @@ -238,6 +261,8 @@ export class Convo { items: [], convo: undefined, error: undefined, + sender: undefined, + recipients: undefined, isFetchingHistory: this.isFetchingHistory, deleteMessage: undefined, sendMessage: undefined, @@ -253,6 +278,8 @@ export class Convo { items: this.getItems(), convo: this.convo!, error: undefined, + sender: this.sender!, + recipients: this.recipients!, isFetchingHistory: this.isFetchingHistory, deleteMessage: this.deleteMessage, sendMessage: this.sendMessage, @@ -265,6 +292,8 @@ export class Convo { items: [], convo: undefined, error: this.error, + sender: undefined, + recipients: undefined, isFetchingHistory: false, deleteMessage: undefined, sendMessage: undefined, @@ -277,6 +306,8 @@ export class Convo { items: [], convo: undefined, error: undefined, + sender: undefined, + recipients: undefined, isFetchingHistory: false, deleteMessage: undefined, sendMessage: undefined, @@ -289,7 +320,10 @@ export class Convo { async init() { logger.debug('Convo: init', {}, logger.DebugContext.convo) - if (this.status === ConvoStatus.Uninitialized) { + if ( + this.status === ConvoStatus.Uninitialized || + this.status === ConvoStatus.Error + ) { try { this.status = ConvoStatus.Initializing this.commit() @@ -301,8 +335,16 @@ export class Convo { await this.fetchMessageHistory() this.pollEvents() - } catch (e) { - this.error = e + } 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() } @@ -318,6 +360,8 @@ export class Convo { this.status === ConvoStatus.Suspended || this.status === ConvoStatus.Backgrounded ) { + const fromStatus = this.status + try { this.status = ConvoStatus.Resuming this.commit() @@ -326,14 +370,24 @@ export class Convo { this.status = ConvoStatus.Ready this.commit() - await this.fetchMessageHistory() + // throw new Error('UNCOMMENT TO TEST RESUME FAILURE') this.pollInterval = ACTIVE_POLL_INTERVAL this.pollEvents() } catch (e) { - // TODO handle errors in one place - this.error = e - this.status = ConvoStatus.Error + 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.status = fromStatus this.commit() } } else { @@ -367,6 +421,19 @@ 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, + ) + + /* + * 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() { @@ -386,7 +453,7 @@ export class Convo { * 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(ConvoError.HistoryFailed)) return + if (this.headerItems.has(ConvoItemError.HistoryFailed)) return try { this.isFetchingHistory = true @@ -435,12 +502,12 @@ export class Convo { } catch (e: any) { logger.error('Convo: failed to fetch message history') - this.headerItems.set(ConvoError.HistoryFailed, { + this.headerItems.set(ConvoItemError.HistoryFailed, { type: 'error-recoverable', - key: ConvoError.HistoryFailed, - code: ConvoError.HistoryFailed, + key: ConvoItemError.HistoryFailed, + code: ConvoItemError.HistoryFailed, retry: () => { - this.headerItems.delete(ConvoError.HistoryFailed) + this.headerItems.delete(ConvoItemError.HistoryFailed) this.fetchMessageHistory() }, }) @@ -457,6 +524,11 @@ export class Convo { ) { if (this.pendingEventIngestion) return + /* + * Represents a failed state, which is retryable. + */ + if (this.pollingFailure) return + setTimeout(async () => { this.pendingEventIngestion = this.ingestLatestEvents() await this.pendingEventIngestion @@ -467,76 +539,94 @@ export class Convo { } async ingestLatestEvents() { - 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 - let needsCommit = false + let needsCommit = false - 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') { + 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 ( - 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) - ) { + if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { /* - * Update if we have this in state. If we don't, don't worry about it. + * Update rev regardless of if it's a log type we care about or not */ - 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) + 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 ( + 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) + ) { + /* + * Update if we have this in state. If we don't, don't worry about it. + */ + 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) { + 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() } }