Bluesky app fork with some witchin' additions 💫

[Clipclops] All my clops gone (#3850)

* Handle two common errors, provide more clarity around error states

* Handle failed polling

* Remove unused error type

* format

authored by

Eric Bailey and committed by
GitHub
0b6ace99 2a1dbd27

+198 -104
+8 -2
src/screens/Messages/Conversation/MessageListError.tsx
··· 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {ConvoError, ConvoItem} from '#/state/messages/convo' 6 + import {ConvoItem, ConvoItemError} from '#/state/messages/convo' 7 7 import {atoms as a, useTheme} from '#/alf' 8 8 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 9 9 import {InlineLinkText} from '#/components/Link' ··· 18 18 const {_} = useLingui() 19 19 const message = React.useMemo(() => { 20 20 return { 21 - [ConvoError.HistoryFailed]: _(msg`Failed to load past messages.`), 21 + [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), 22 + [ConvoItemError.ResumeFailed]: _( 23 + msg`There was an issue connecting to the chat.`, 24 + ), 25 + [ConvoItemError.PollFailed]: _( 26 + msg`This chat was disconnected due to a network error.`, 27 + ), 22 28 }[item.code] 23 29 }, [_, item.code]) 24 30
+2 -6
src/screens/Messages/Conversation/MessagesList.tsx
··· 229 229 <ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}> 230 230 <List 231 231 ref={flatListRef} 232 - data={chat.status === ConvoStatus.Ready ? chat.items : undefined} 232 + data={chat.items} 233 233 renderItem={renderItem} 234 234 keyExtractor={keyExtractor} 235 235 disableVirtualization={true} ··· 248 248 onScrollToIndexFailed={onScrollToIndexFailed} 249 249 scrollEventThrottle={100} 250 250 ListHeaderComponent={ 251 - <MaybeLoader 252 - isLoading={ 253 - chat.status === ConvoStatus.Ready && chat.isFetchingHistory 254 - } 255 - /> 251 + <MaybeLoader isLoading={chat.isFetchingHistory} /> 256 252 } 257 253 /> 258 254 </ScrollProvider>
+14 -16
src/screens/Messages/Conversation/index.tsx
··· 14 14 import {isWeb} from 'platform/detection' 15 15 import {ChatProvider, useChat} from 'state/messages' 16 16 import {ConvoStatus} from 'state/messages/convo' 17 - import {useSession} from 'state/session' 18 17 import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' 19 18 import {CenteredView} from 'view/com/util/Views' 20 19 import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' ··· 43 42 44 43 function Inner() { 45 44 const chat = useChat() 46 - const {currentAccount} = useSession() 47 - const myDid = currentAccount?.did 48 45 49 - const otherProfile = React.useMemo(() => { 50 - if (chat.status !== ConvoStatus.Ready) return 51 - return chat.convo.members.find(m => m.did !== myDid) 52 - }, [chat, myDid]) 46 + if ( 47 + chat.status === ConvoStatus.Uninitialized || 48 + chat.status === ConvoStatus.Initializing 49 + ) { 50 + return <ListMaybePlaceholder isLoading /> 51 + } 53 52 54 - // TODO whenever we have error messages, we should use them in here -hailey 55 - if (chat.status !== ConvoStatus.Ready || !otherProfile) { 56 - return ( 57 - <ListMaybePlaceholder 58 - isLoading={true} 59 - isError={chat.status === ConvoStatus.Error} 60 - /> 61 - ) 53 + if (chat.status === ConvoStatus.Error) { 54 + // TODO error 55 + return null 62 56 } 63 57 58 + /* 59 + * Any other chat states (atm) are "ready" states 60 + */ 61 + 64 62 return ( 65 63 <KeyboardProvider> 66 64 <CenteredView style={{flex: 1}} sideBorders> 67 - <Header profile={otherProfile} /> 65 + <Header profile={chat.recipients[0]} /> 68 66 <MessagesList /> 69 67 </CenteredView> 70 68 </KeyboardProvider>
+10 -6
src/state/messages/__tests__/convo.test.ts
··· 1 1 import {describe, it} from '@jest/globals' 2 2 3 3 describe(`#/state/messages/convo`, () => { 4 - describe(`status states`, () => { 4 + describe(`init`, () => { 5 + it.todo(`fails if sender and recipients aren't found`) 5 6 it.todo(`cannot re-initialize from a non-unintialized state`) 6 7 it.todo(`can re-initialize from a failed state`) 8 + }) 7 9 8 - describe(`destroy`, () => { 9 - it.todo(`cannot be interacted with when destroyed`) 10 - it.todo(`polling is stopped when destroyed`) 11 - it.todo(`events are cleaned up when destroyed`) 12 - }) 10 + describe(`resume`, () => { 11 + it.todo(`restores previous state if resume fails`) 12 + }) 13 + 14 + describe(`suspend`, () => { 15 + it.todo(`cannot be interacted with when suspended`) 16 + it.todo(`polling is stopped when suspended`) 13 17 }) 14 18 15 19 describe(`read states`, () => {
+164 -74
src/state/messages/convo.ts
··· 25 25 Suspended = 'suspended', 26 26 } 27 27 28 - export enum ConvoError { 28 + export enum ConvoItemError { 29 29 HistoryFailed = 'historyFailed', 30 + ResumeFailed = 'resumeFailed', 31 + PollFailed = 'pollFailed', 32 + } 33 + 34 + export enum ConvoError { 35 + InitFailed = 'initFailed', 30 36 } 31 37 32 38 export type ConvoItem = ··· 56 62 | { 57 63 type: 'error-recoverable' 58 64 key: string 59 - code: ConvoError 65 + code: ConvoItemError 60 66 retry: () => void 61 - } 62 - | { 63 - type: 'error-fatal' 64 - code: ConvoError 65 - key: string 66 67 } 67 68 68 69 export type ConvoState = ··· 71 72 items: [] 72 73 convo: undefined 73 74 error: undefined 75 + sender: undefined 76 + recipients: undefined 74 77 isFetchingHistory: false 75 78 deleteMessage: undefined 76 79 sendMessage: undefined ··· 81 84 items: [] 82 85 convo: undefined 83 86 error: undefined 87 + sender: undefined 88 + recipients: undefined 84 89 isFetchingHistory: boolean 85 90 deleteMessage: undefined 86 91 sendMessage: undefined ··· 91 96 items: ConvoItem[] 92 97 convo: ChatBskyConvoDefs.ConvoView 93 98 error: undefined 99 + sender: AppBskyActorDefs.ProfileViewBasic 100 + recipients: AppBskyActorDefs.ProfileViewBasic[] 94 101 isFetchingHistory: boolean 95 102 deleteMessage: (messageId: string) => Promise<void> 96 103 sendMessage: ( ··· 103 110 items: ConvoItem[] 104 111 convo: ChatBskyConvoDefs.ConvoView 105 112 error: undefined 113 + sender: AppBskyActorDefs.ProfileViewBasic 114 + recipients: AppBskyActorDefs.ProfileViewBasic[] 106 115 isFetchingHistory: boolean 107 116 deleteMessage: (messageId: string) => Promise<void> 108 117 sendMessage: ( ··· 115 124 items: ConvoItem[] 116 125 convo: ChatBskyConvoDefs.ConvoView 117 126 error: undefined 127 + sender: AppBskyActorDefs.ProfileViewBasic 128 + recipients: AppBskyActorDefs.ProfileViewBasic[] 118 129 isFetchingHistory: boolean 119 130 deleteMessage: (messageId: string) => Promise<void> 120 131 sendMessage: ( ··· 127 138 items: ConvoItem[] 128 139 convo: ChatBskyConvoDefs.ConvoView 129 140 error: undefined 141 + sender: AppBskyActorDefs.ProfileViewBasic 142 + recipients: AppBskyActorDefs.ProfileViewBasic[] 130 143 isFetchingHistory: boolean 131 144 deleteMessage: (messageId: string) => Promise<void> 132 145 sendMessage: ( ··· 139 152 items: [] 140 153 convo: undefined 141 154 error: any 155 + sender: undefined 156 + recipients: undefined 142 157 isFetchingHistory: false 143 158 deleteMessage: undefined 144 159 sendMessage: undefined ··· 165 180 166 181 private pollInterval = ACTIVE_POLL_INTERVAL 167 182 private status: ConvoStatus = ConvoStatus.Uninitialized 168 - private error: any 183 + private error: 184 + | { 185 + code: ConvoError 186 + exception?: Error 187 + retry: () => void 188 + } 189 + | undefined 169 190 private historyCursor: string | undefined | null = undefined 170 191 private isFetchingHistory = false 171 192 private eventsCursor: string | undefined = undefined 193 + private pollingFailure = false 172 194 173 195 private pastMessages: Map< 174 196 string, ··· 192 214 convoId: string 193 215 convo: ChatBskyConvoDefs.ConvoView | undefined 194 216 sender: AppBskyActorDefs.ProfileViewBasic | undefined 217 + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined 195 218 snapshot: ConvoState | undefined 196 219 197 220 constructor(params: ConvoParams) { ··· 226 249 227 250 getSnapshot(): ConvoState { 228 251 if (!this.snapshot) this.snapshot = this.generateSnapshot() 229 - logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) 252 + // logger.debug('Convo: snapshotted', {}, logger.DebugContext.convo) 230 253 return this.snapshot 231 254 } 232 255 ··· 238 261 items: [], 239 262 convo: undefined, 240 263 error: undefined, 264 + sender: undefined, 265 + recipients: undefined, 241 266 isFetchingHistory: this.isFetchingHistory, 242 267 deleteMessage: undefined, 243 268 sendMessage: undefined, ··· 253 278 items: this.getItems(), 254 279 convo: this.convo!, 255 280 error: undefined, 281 + sender: this.sender!, 282 + recipients: this.recipients!, 256 283 isFetchingHistory: this.isFetchingHistory, 257 284 deleteMessage: this.deleteMessage, 258 285 sendMessage: this.sendMessage, ··· 265 292 items: [], 266 293 convo: undefined, 267 294 error: this.error, 295 + sender: undefined, 296 + recipients: undefined, 268 297 isFetchingHistory: false, 269 298 deleteMessage: undefined, 270 299 sendMessage: undefined, ··· 277 306 items: [], 278 307 convo: undefined, 279 308 error: undefined, 309 + sender: undefined, 310 + recipients: undefined, 280 311 isFetchingHistory: false, 281 312 deleteMessage: undefined, 282 313 sendMessage: undefined, ··· 289 320 async init() { 290 321 logger.debug('Convo: init', {}, logger.DebugContext.convo) 291 322 292 - if (this.status === ConvoStatus.Uninitialized) { 323 + if ( 324 + this.status === ConvoStatus.Uninitialized || 325 + this.status === ConvoStatus.Error 326 + ) { 293 327 try { 294 328 this.status = ConvoStatus.Initializing 295 329 this.commit() ··· 301 335 await this.fetchMessageHistory() 302 336 303 337 this.pollEvents() 304 - } catch (e) { 305 - this.error = e 338 + } catch (e: any) { 339 + logger.error('Convo: failed to init') 340 + this.error = { 341 + exception: e, 342 + code: ConvoError.InitFailed, 343 + retry: () => { 344 + this.error = undefined 345 + this.init() 346 + }, 347 + } 306 348 this.status = ConvoStatus.Error 307 349 this.commit() 308 350 } ··· 318 360 this.status === ConvoStatus.Suspended || 319 361 this.status === ConvoStatus.Backgrounded 320 362 ) { 363 + const fromStatus = this.status 364 + 321 365 try { 322 366 this.status = ConvoStatus.Resuming 323 367 this.commit() ··· 326 370 this.status = ConvoStatus.Ready 327 371 this.commit() 328 372 329 - await this.fetchMessageHistory() 373 + // throw new Error('UNCOMMENT TO TEST RESUME FAILURE') 330 374 331 375 this.pollInterval = ACTIVE_POLL_INTERVAL 332 376 this.pollEvents() 333 377 } catch (e) { 334 - // TODO handle errors in one place 335 - this.error = e 336 - this.status = ConvoStatus.Error 378 + logger.error('Convo: failed to resume') 379 + 380 + this.footerItems.set(ConvoItemError.ResumeFailed, { 381 + type: 'error-recoverable', 382 + key: ConvoItemError.ResumeFailed, 383 + code: ConvoItemError.ResumeFailed, 384 + retry: () => { 385 + this.footerItems.delete(ConvoItemError.ResumeFailed) 386 + this.resume() 387 + }, 388 + }) 389 + 390 + this.status = fromStatus 337 391 this.commit() 338 392 } 339 393 } else { ··· 367 421 ) 368 422 this.convo = response.data.convo 369 423 this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid) 424 + this.recipients = this.convo.members.filter( 425 + m => m.did !== this.__tempFromUserDid, 426 + ) 427 + 428 + /* 429 + * Prevent invalid states 430 + */ 431 + if (!this.sender) { 432 + throw new Error('Convo: could not find sender in convo') 433 + } 434 + if (!this.recipients) { 435 + throw new Error('Convo: could not find recipients in convo') 436 + } 370 437 } 371 438 372 439 async fetchMessageHistory() { ··· 386 453 * If we've rendered a retry state for history fetching, exit. Upon retry, 387 454 * this will be removed and we'll try again. 388 455 */ 389 - if (this.headerItems.has(ConvoError.HistoryFailed)) return 456 + if (this.headerItems.has(ConvoItemError.HistoryFailed)) return 390 457 391 458 try { 392 459 this.isFetchingHistory = true ··· 435 502 } catch (e: any) { 436 503 logger.error('Convo: failed to fetch message history') 437 504 438 - this.headerItems.set(ConvoError.HistoryFailed, { 505 + this.headerItems.set(ConvoItemError.HistoryFailed, { 439 506 type: 'error-recoverable', 440 - key: ConvoError.HistoryFailed, 441 - code: ConvoError.HistoryFailed, 507 + key: ConvoItemError.HistoryFailed, 508 + code: ConvoItemError.HistoryFailed, 442 509 retry: () => { 443 - this.headerItems.delete(ConvoError.HistoryFailed) 510 + this.headerItems.delete(ConvoItemError.HistoryFailed) 444 511 this.fetchMessageHistory() 445 512 }, 446 513 }) ··· 457 524 ) { 458 525 if (this.pendingEventIngestion) return 459 526 527 + /* 528 + * Represents a failed state, which is retryable. 529 + */ 530 + if (this.pollingFailure) return 531 + 460 532 setTimeout(async () => { 461 533 this.pendingEventIngestion = this.ingestLatestEvents() 462 534 await this.pendingEventIngestion ··· 467 539 } 468 540 469 541 async ingestLatestEvents() { 470 - const response = await this.agent.api.chat.bsky.convo.getLog( 471 - { 472 - cursor: this.eventsCursor, 473 - }, 474 - { 475 - headers: { 476 - Authorization: this.__tempFromUserDid, 542 + try { 543 + // throw new Error('UNCOMMENT TO TEST POLL FAILURE') 544 + const response = await this.agent.api.chat.bsky.convo.getLog( 545 + { 546 + cursor: this.eventsCursor, 547 + }, 548 + { 549 + headers: { 550 + Authorization: this.__tempFromUserDid, 551 + }, 477 552 }, 478 - }, 479 - ) 480 - const {logs} = response.data 553 + ) 554 + const {logs} = response.data 481 555 482 - let needsCommit = false 556 + let needsCommit = false 483 557 484 - for (const log of logs) { 485 - /* 486 - * If there's a rev, we should handle it. If there's not a rev, we don't 487 - * know what it is. 488 - */ 489 - if (typeof log.rev === 'string') { 558 + for (const log of logs) { 490 559 /* 491 - * We only care about new events 560 + * If there's a rev, we should handle it. If there's not a rev, we don't 561 + * know what it is. 492 562 */ 493 - if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { 563 + if (typeof log.rev === 'string') { 494 564 /* 495 - * Update rev regardless of if it's a log type we care about or not 565 + * We only care about new events 496 566 */ 497 - this.eventsCursor = log.rev 498 - 499 - /* 500 - * This is VERY important. We don't want to insert any messages from 501 - * your other chats. 502 - */ 503 - if (log.convoId !== this.convoId) continue 567 + if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) { 568 + /* 569 + * Update rev regardless of if it's a log type we care about or not 570 + */ 571 + this.eventsCursor = log.rev 504 572 505 - if ( 506 - ChatBskyConvoDefs.isLogCreateMessage(log) && 507 - ChatBskyConvoDefs.isMessageView(log.message) 508 - ) { 509 - if (this.newMessages.has(log.message.id)) { 510 - // Trust the log as the source of truth on ordering 511 - this.newMessages.delete(log.message.id) 512 - } 513 - this.newMessages.set(log.message.id, log.message) 514 - needsCommit = true 515 - } else if ( 516 - ChatBskyConvoDefs.isLogDeleteMessage(log) && 517 - ChatBskyConvoDefs.isDeletedMessageView(log.message) 518 - ) { 519 573 /* 520 - * Update if we have this in state. If we don't, don't worry about it. 574 + * This is VERY important. We don't want to insert any messages from 575 + * your other chats. 521 576 */ 522 - if (this.pastMessages.has(log.message.id)) { 577 + if (log.convoId !== this.convoId) continue 578 + 579 + if ( 580 + ChatBskyConvoDefs.isLogCreateMessage(log) && 581 + ChatBskyConvoDefs.isMessageView(log.message) 582 + ) { 583 + if (this.newMessages.has(log.message.id)) { 584 + // Trust the log as the source of truth on ordering 585 + this.newMessages.delete(log.message.id) 586 + } 587 + this.newMessages.set(log.message.id, log.message) 588 + needsCommit = true 589 + } else if ( 590 + ChatBskyConvoDefs.isLogDeleteMessage(log) && 591 + ChatBskyConvoDefs.isDeletedMessageView(log.message) 592 + ) { 523 593 /* 524 - * For now, we remove deleted messages from the thread, if we receive one. 525 - * 526 - * To support them, it'd look something like this: 527 - * this.pastMessages.set(log.message.id, log.message) 594 + * Update if we have this in state. If we don't, don't worry about it. 528 595 */ 529 - this.pastMessages.delete(log.message.id) 530 - this.newMessages.delete(log.message.id) 531 - this.deletedMessages.delete(log.message.id) 532 - needsCommit = true 596 + if (this.pastMessages.has(log.message.id)) { 597 + /* 598 + * For now, we remove deleted messages from the thread, if we receive one. 599 + * 600 + * To support them, it'd look something like this: 601 + * this.pastMessages.set(log.message.id, log.message) 602 + */ 603 + this.pastMessages.delete(log.message.id) 604 + this.newMessages.delete(log.message.id) 605 + this.deletedMessages.delete(log.message.id) 606 + needsCommit = true 607 + } 533 608 } 534 609 } 535 610 } 536 611 } 537 - } 538 612 539 - if (needsCommit) { 613 + if (needsCommit) { 614 + this.commit() 615 + } 616 + } catch (e: any) { 617 + logger.error('Convo: failed to poll events') 618 + this.pollingFailure = true 619 + this.footerItems.set(ConvoItemError.PollFailed, { 620 + type: 'error-recoverable', 621 + key: ConvoItemError.PollFailed, 622 + code: ConvoItemError.PollFailed, 623 + retry: () => { 624 + this.footerItems.delete(ConvoItemError.PollFailed) 625 + this.pollingFailure = false 626 + this.commit() 627 + this.pollEvents() 628 + }, 629 + }) 540 630 this.commit() 541 631 } 542 632 }