@droppii-org/chat-mobile 0.2.109 → 0.2.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/lib/module/screens/chat-detail/ChatDetail.js +42 -7
  2. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  3. package/lib/module/screens/chat-detail/ChatListLegend.js +249 -49
  4. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  5. package/lib/module/screens/chat-detail/hooks/createUnreadMarkReadGate.js +32 -0
  6. package/lib/module/screens/chat-detail/hooks/createUnreadMarkReadGate.js.map +1 -0
  7. package/lib/module/screens/chat-detail/hooks/useMarkConversationRead.js +180 -0
  8. package/lib/module/screens/chat-detail/hooks/useMarkConversationRead.js.map +1 -0
  9. package/lib/module/screens/chat-detail/hooks/useUnreadAnchorSeen.js +88 -0
  10. package/lib/module/screens/chat-detail/hooks/useUnreadAnchorSeen.js.map +1 -0
  11. package/lib/module/screens/chat-detail/hooks/useUnreadDeeplinkCheck.js +59 -0
  12. package/lib/module/screens/chat-detail/hooks/useUnreadDeeplinkCheck.js.map +1 -0
  13. package/lib/module/screens/chat-detail/hooks/useUnreadIndicator.js +105 -0
  14. package/lib/module/screens/chat-detail/hooks/useUnreadIndicator.js.map +1 -0
  15. package/lib/module/screens/chat-detail/hooks/useUnreadIndicatorInList.js +157 -0
  16. package/lib/module/screens/chat-detail/hooks/useUnreadIndicatorInList.js.map +1 -0
  17. package/lib/module/screens/chat-detail/legend/LegendChatUnreadButton.js +140 -0
  18. package/lib/module/screens/chat-detail/legend/LegendChatUnreadButton.js.map +1 -0
  19. package/lib/module/screens/chat-detail/legend/LegendChatUnreadDivider.js +51 -0
  20. package/lib/module/screens/chat-detail/legend/LegendChatUnreadDivider.js.map +1 -0
  21. package/lib/module/services/message.js +30 -0
  22. package/lib/module/services/message.js.map +1 -1
  23. package/lib/module/store/message.js +25 -4
  24. package/lib/module/store/message.js.map +1 -1
  25. package/lib/module/store/unreadIndicator.js +26 -0
  26. package/lib/module/store/unreadIndicator.js.map +1 -0
  27. package/lib/module/translation/resources/i18n.js +2 -0
  28. package/lib/module/translation/resources/i18n.js.map +1 -1
  29. package/lib/module/types/chat.js.map +1 -1
  30. package/lib/module/types/unreadIndicator.js +2 -0
  31. package/lib/module/types/unreadIndicator.js.map +1 -0
  32. package/lib/module/utils/markReadPolicy.js +25 -0
  33. package/lib/module/utils/markReadPolicy.js.map +1 -0
  34. package/lib/module/utils/promise.js +16 -0
  35. package/lib/module/utils/promise.js.map +1 -1
  36. package/lib/module/utils/unreadAnchor.js +57 -0
  37. package/lib/module/utils/unreadAnchor.js.map +1 -0
  38. package/lib/module/utils/unreadIndicatorState.js +96 -0
  39. package/lib/module/utils/unreadIndicatorState.js.map +1 -0
  40. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  41. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +4 -1
  42. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  43. package/lib/typescript/src/screens/chat-detail/hooks/createUnreadMarkReadGate.d.ts +9 -0
  44. package/lib/typescript/src/screens/chat-detail/hooks/createUnreadMarkReadGate.d.ts.map +1 -0
  45. package/lib/typescript/src/screens/chat-detail/hooks/useMarkConversationRead.d.ts +39 -0
  46. package/lib/typescript/src/screens/chat-detail/hooks/useMarkConversationRead.d.ts.map +1 -0
  47. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadAnchorSeen.d.ts +26 -0
  48. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadAnchorSeen.d.ts.map +1 -0
  49. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadDeeplinkCheck.d.ts +17 -0
  50. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadDeeplinkCheck.d.ts.map +1 -0
  51. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicator.d.ts +21 -0
  52. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicator.d.ts.map +1 -0
  53. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicatorInList.d.ts +35 -0
  54. package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicatorInList.d.ts.map +1 -0
  55. package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadButton.d.ts +8 -0
  56. package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadButton.d.ts.map +1 -0
  57. package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadDivider.d.ts +7 -0
  58. package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadDivider.d.ts.map +1 -0
  59. package/lib/typescript/src/services/message.d.ts +8 -0
  60. package/lib/typescript/src/services/message.d.ts.map +1 -1
  61. package/lib/typescript/src/store/message.d.ts.map +1 -1
  62. package/lib/typescript/src/store/unreadIndicator.d.ts +10 -0
  63. package/lib/typescript/src/store/unreadIndicator.d.ts.map +1 -0
  64. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  65. package/lib/typescript/src/types/chat.d.ts +3 -0
  66. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  67. package/lib/typescript/src/types/events.d.ts +17 -1
  68. package/lib/typescript/src/types/events.d.ts.map +1 -1
  69. package/lib/typescript/src/types/unreadIndicator.d.ts +45 -0
  70. package/lib/typescript/src/types/unreadIndicator.d.ts.map +1 -0
  71. package/lib/typescript/src/utils/markReadPolicy.d.ts +27 -0
  72. package/lib/typescript/src/utils/markReadPolicy.d.ts.map +1 -0
  73. package/lib/typescript/src/utils/promise.d.ts +6 -0
  74. package/lib/typescript/src/utils/promise.d.ts.map +1 -1
  75. package/lib/typescript/src/utils/unreadAnchor.d.ts +33 -0
  76. package/lib/typescript/src/utils/unreadAnchor.d.ts.map +1 -0
  77. package/lib/typescript/src/utils/unreadIndicatorState.d.ts +26 -0
  78. package/lib/typescript/src/utils/unreadIndicatorState.d.ts.map +1 -0
  79. package/package.json +2 -2
  80. package/src/screens/chat-detail/ChatDetail.tsx +58 -5
  81. package/src/screens/chat-detail/ChatListLegend.tsx +314 -46
  82. package/src/screens/chat-detail/hooks/createUnreadMarkReadGate.ts +41 -0
  83. package/src/screens/chat-detail/hooks/useMarkConversationRead.ts +197 -0
  84. package/src/screens/chat-detail/hooks/useUnreadAnchorSeen.ts +105 -0
  85. package/src/screens/chat-detail/hooks/useUnreadDeeplinkCheck.ts +68 -0
  86. package/src/screens/chat-detail/hooks/useUnreadIndicator.ts +112 -0
  87. package/src/screens/chat-detail/hooks/useUnreadIndicatorInList.ts +181 -0
  88. package/src/screens/chat-detail/legend/LegendChatUnreadButton.tsx +165 -0
  89. package/src/screens/chat-detail/legend/LegendChatUnreadDivider.tsx +52 -0
  90. package/src/services/message.ts +42 -0
  91. package/src/store/message.ts +23 -5
  92. package/src/store/unreadIndicator.ts +38 -0
  93. package/src/translation/resources/i18n.ts +2 -0
  94. package/src/types/chat.ts +3 -0
  95. package/src/types/events.ts +18 -1
  96. package/src/types/unreadIndicator.ts +49 -0
  97. package/src/utils/markReadPolicy.ts +55 -0
  98. package/src/utils/promise.ts +20 -0
  99. package/src/utils/unreadAnchor.ts +87 -0
  100. package/src/utils/unreadIndicatorState.ts +131 -0
@@ -63,7 +63,6 @@ import { messageStyles } from './legend/messageTypes';
63
63
  import { MessageType, PeerType } from '@droppii/openim-rn-client-sdk';
64
64
  import type { MessageItem } from '@droppii/openim-rn-client-sdk';
65
65
  import { useUserStore } from '../../store';
66
- import { ChatMessageAPI } from '../../services';
67
66
  import { resolveImageDisplayDims } from '../../components/messages/imageMessage';
68
67
  import ChatImageDimens, {
69
68
  CHAT_IMAGE_VIEW_SOURCE,
@@ -74,6 +73,16 @@ import { useGuardedPress } from '../../hooks/useGuardedPress';
74
73
  import { useDismissKeyboardFirst } from '../../hooks/useDismissKeyboardFirst';
75
74
  import { useTimelinePressGuard } from '../../context/TimelinePressGuardContext';
76
75
  import { perfMark } from '../../utils/perf-marker';
76
+ import { useMarkConversationRead } from './hooks/useMarkConversationRead';
77
+ import { createUnreadMarkReadGate } from './hooks/createUnreadMarkReadGate';
78
+ import { useUnreadIndicatorInList } from './hooks/useUnreadIndicatorInList';
79
+ import { LegendChatUnreadDivider } from './legend/LegendChatUnreadDivider';
80
+ import { LegendChatUnreadButton } from './legend/LegendChatUnreadButton';
81
+
82
+ // Safety net: a jump that never reports back can't keep the pill logic and
83
+ // the window-reload edge stuck. Long enough for a slow 3-call search plus the
84
+ // unread jump's older-history paging.
85
+ const JUMP_IN_FLIGHT_TIMEOUT_MS = 20000;
77
86
 
78
87
  // Cluster spacing: a wide gap after the message that closes a cluster, a tight
79
88
  // one between messages inside it. These override the flat margin on the row
@@ -146,6 +155,13 @@ interface ChatListLegendInternalProps extends ChatListProps {
146
155
  // While true the content is non-hittable: taps only dismiss the panel.
147
156
  isComposerPanelOpen?: boolean;
148
157
  onDismissAreaScrollBeginDrag?: () => void;
158
+ // Unread-indicator session of the owning ChatDetail (useUnreadIndicator):
159
+ // the token as state (selectors) and as a ref (listeners, async code).
160
+ unreadToken?: number | null;
161
+ unreadTokenRef?: RefObject<number | null>;
162
+ // Deeplink target the screen was opened on (or re-pointed to): the first
163
+ // list load for it never marks read.
164
+ deeplinkTargetId?: string;
149
165
  }
150
166
 
151
167
  export const ChatListLegend = memo(
@@ -170,6 +186,9 @@ export const ChatListLegend = memo(
170
186
  dismissAreaTouchProps,
171
187
  isComposerPanelOpen = false,
172
188
  onDismissAreaScrollBeginDrag,
189
+ unreadToken = null,
190
+ unreadTokenRef,
191
+ deeplinkTargetId,
173
192
  }: ChatListLegendInternalProps) => {
174
193
  const currentUserId = useUserStore((s) => s.user?.userID);
175
194
  const windowWidth = useWindowDimensions().width;
@@ -291,6 +310,76 @@ export const ChatListLegend = memo(
291
310
  const { applicationType } = useChatContext();
292
311
  const searchMessages = useMessageStore((s) => s.searchMessages);
293
312
 
313
+ // True once the list has loaded (onLoad); false while it's remounting
314
+ // under the spinner. Read by the mark-read policy.
315
+ const listLoadedRef = useRef(false);
316
+ // A ScrollToMessage jump is running (in-window scroll or search fallback).
317
+ const jumpInFlightRef = useRef(false);
318
+ // Bumped on every ScrollToMessage, to tell whether a search re-emitted.
319
+ const scrollEventSeqRef = useRef(0);
320
+ const jumpInFlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
321
+ null
322
+ );
323
+ const setJumpInFlight = useCallback((inFlight: boolean) => {
324
+ jumpInFlightRef.current = inFlight;
325
+ if (jumpInFlightTimerRef.current) {
326
+ clearTimeout(jumpInFlightTimerRef.current);
327
+ jumpInFlightTimerRef.current = null;
328
+ }
329
+ if (!inFlight) return;
330
+ jumpInFlightTimerRef.current = setTimeout(() => {
331
+ jumpInFlightTimerRef.current = null;
332
+ jumpInFlightRef.current = false;
333
+ }, JUMP_IN_FLIGHT_TIMEOUT_MS);
334
+ }, []);
335
+
336
+ const markReadGate = useMemo(
337
+ () =>
338
+ unreadTokenRef ? createUnreadMarkReadGate(unreadTokenRef) : undefined,
339
+ [unreadTokenRef]
340
+ );
341
+ const { requestMarkRead, flushMarkRead } = useMarkConversationRead({
342
+ conversationId,
343
+ isAtBottomRef,
344
+ listLoadedRef,
345
+ hasMoreNew: !!hasMoreNew,
346
+ deeplinkTargetId,
347
+ gate: markReadGate,
348
+ });
349
+
350
+ // A session that begins already open (bot thread: no lookup) has no
351
+ // resolve transition for the gate to report, so retry a pending
352
+ // mark-read when it lands. Flush-only: landing is not a trigger.
353
+ useEffect(() => {
354
+ if (unreadToken != null) flushMarkRead('gate-open');
355
+ }, [unreadToken, flushMarkRead]);
356
+
357
+ const {
358
+ unreadAnchorRowId,
359
+ isPillVisible,
360
+ viewabilityConfig,
361
+ handleViewableItemsChanged,
362
+ checkAnchorSeen,
363
+ markPositionSettled,
364
+ jumpToFirstUnread,
365
+ } = useUnreadIndicatorInList({
366
+ messages,
367
+ unreadToken,
368
+ isLoading: !!isLoading,
369
+ hasMoreOld: !!hasMoreOld,
370
+ moreOldLoading: !!moreOldLoading,
371
+ onLoadMoreOld,
372
+ isBotConversation,
373
+ listRef,
374
+ jumpInFlightRef,
375
+ setJumpInFlight,
376
+ });
377
+ // With a composer panel open the tap only dismisses it (DROPPII-30577).
378
+ const onPressUnreadPill = useGuardedPress(
379
+ jumpToFirstUnread,
380
+ shouldSwallowPress
381
+ );
382
+
294
383
  const handleMessageLongPress = useCallback(
295
384
  (messageId: string, message: MessageItem) => {
296
385
  if (shouldSwallowPress()) return;
@@ -372,9 +461,14 @@ export const ChatListLegend = memo(
372
461
  );
373
462
  }
374
463
 
464
+ // The first unread row always carries its time label, under the
465
+ // divider.
466
+ const isUnreadAnchor = messageId === unreadAnchorRowId;
467
+
375
468
  return (
376
469
  <>
377
- {(dayStart || timeBreak) && (
470
+ {isUnreadAnchor && <LegendChatUnreadDivider />}
471
+ {(isUnreadAnchor || dayStart || timeBreak) && (
378
472
  <LegendChatDay createdAt={createdAt} />
379
473
  )}
380
474
  <KContainer.View
@@ -449,6 +543,7 @@ export const ChatListLegend = memo(
449
543
  highlightedId,
450
544
  revealedMessageId,
451
545
  handleMessagePress,
546
+ unreadAnchorRowId,
452
547
  ]
453
548
  );
454
549
 
@@ -490,6 +585,9 @@ export const ChatListLegend = memo(
490
585
  // Both separators add chrome above the row that this function does not
491
586
  // account for, and a resolved size is never re-measured.
492
587
  if (messageData?.dayStart || messageData?.timeBreak) return undefined;
588
+ // Same for the unread divider. A late anchor changes this function's
589
+ // identity, which makes LegendList re-measure the row.
590
+ if (messageId === unreadAnchorRowId) return undefined;
493
591
 
494
592
  // Incoming rows in a group chat carry an avatar column and a sender
495
593
  // name whose height this function cannot predict — measure them.
@@ -557,6 +655,7 @@ export const ChatListLegend = memo(
557
655
  isBotConversation,
558
656
  conversationFlow?.flowCurrentStep,
559
657
  windowWidth,
658
+ unreadAnchorRowId,
560
659
  ]
561
660
  );
562
661
 
@@ -571,6 +670,7 @@ export const ChatListLegend = memo(
571
670
  lastMessageId,
572
671
  highlightedId,
573
672
  revealedMessageId,
673
+ unreadAnchorRowId,
574
674
  }),
575
675
  [
576
676
  messageDataMap,
@@ -578,6 +678,7 @@ export const ChatListLegend = memo(
578
678
  lastMessageId,
579
679
  highlightedId,
580
680
  revealedMessageId,
681
+ unreadAnchorRowId,
581
682
  ]
582
683
  );
583
684
 
@@ -601,8 +702,10 @@ export const ChatListLegend = memo(
601
702
 
602
703
  const handleScrollBeginDrag = useCallback(() => {
603
704
  hasUserScrolledRef.current = true;
705
+ // The user has taken over: whatever is on screen now is real.
706
+ markPositionSettled();
604
707
  onDismissAreaScrollBeginDrag?.();
605
- }, [onDismissAreaScrollBeginDrag]);
708
+ }, [onDismissAreaScrollBeginDrag, markPositionSettled]);
606
709
 
607
710
  const handleScroll = useCallback(
608
711
  (e: any) => {
@@ -635,6 +738,7 @@ export const ChatListLegend = memo(
635
738
  if (
636
739
  grew &&
637
740
  distanceFromEnd > 1 &&
741
+ !jumpInFlightRef.current &&
638
742
  Date.now() < anchorToEndUntilRef.current
639
743
  ) {
640
744
  scrollMessageToEnd({ animated: true, closeKeyboard: false });
@@ -646,19 +750,22 @@ export const ChatListLegend = memo(
646
750
  }
647
751
 
648
752
  scrollDebounceRef.current = setTimeout(() => {
649
- if (isAtBottomRef.current !== isAtBottom) {
753
+ const wasAtBottom = isAtBottomRef.current;
754
+ if (wasAtBottom !== isAtBottom) {
650
755
  isAtBottomRef.current = isAtBottom;
651
756
  buttonOpacity.value = withTiming(
652
757
  isAtBottom ? 0 : 1,
653
758
  BUTTON_FADE_CONFIG
654
759
  );
655
760
  }
656
- if (isAtBottom) {
657
- ChatMessageAPI.markConversationAsRead(conversationId || '');
658
- }
761
+ // Edge-triggered: each call is a server round-trip since rc43.
762
+ if (isAtBottom && !wasAtBottom) requestMarkRead('reached-bottom');
763
+ // Covers slow scrolling while the anchor row stays viewable (no
764
+ // viewability change event). Cheap unless that row is viewable.
765
+ checkAnchorSeen();
659
766
  }, 50);
660
767
  },
661
- [buttonOpacity, conversationId, scrollMessageToEnd]
768
+ [buttonOpacity, scrollMessageToEnd, requestMarkRead, checkAnchorSeen]
662
769
  );
663
770
 
664
771
  const handleScrollToBottom = useCallback(() => {
@@ -675,7 +782,14 @@ export const ChatListLegend = memo(
675
782
  buttonOpacity.value = withTiming(0, BUTTON_FADE_CONFIG);
676
783
  isAtBottomRef.current = true;
677
784
  setShowBadge(false);
678
- }, [hasMoreNew, onLoadMoreOld, scrollMessageToEnd, buttonOpacity]);
785
+ requestMarkRead('fab');
786
+ }, [
787
+ hasMoreNew,
788
+ onLoadMoreOld,
789
+ scrollMessageToEnd,
790
+ buttonOpacity,
791
+ requestMarkRead,
792
+ ]);
679
793
 
680
794
  // `paddingBottom` here replaces the one `styles.content` sets via
681
795
  // `paddingVertical`, so the base spacing has to be added back or the last
@@ -882,9 +996,30 @@ export const ChatListLegend = memo(
882
996
  // sent or received). Skipped while prepending old history or scrolled
883
997
  // away from the bottom. `moreOldLoading` is read via ref, not a
884
998
  // dependency, so its own start/finish toggle doesn't re-run this.
999
+ const prevLoadingRef = useRef(!!isLoading);
885
1000
  useEffect(() => {
1001
+ const wasLoading = prevLoadingRef.current;
1002
+ prevLoadingRef.current = !!isLoading;
1003
+ // Keep the previous id through the spinner.
1004
+ if (isLoading) return;
886
1005
  const previousLastMessageId = previousLastMessageIdRef.current;
887
1006
  previousLastMessageIdRef.current = lastMessageId;
1007
+ if (previousLastMessageId === lastMessageId) return;
1008
+ // An id change landing with the spinner ending is a window replace
1009
+ // (searchMessages / initial fetch), not a new message. Re-aiming at the
1010
+ // end here would yank a jump back to the bottom mid-scroll.
1011
+ if (wasLoading) return;
1012
+
1013
+ // Outside `shouldFire`, so it also counts during an old-history prepend.
1014
+ // Not for the first id: that's whatever window the list mounted with.
1015
+ const last = messagesRef.current[messagesRef.current.length - 1];
1016
+ if (
1017
+ previousLastMessageId !== null &&
1018
+ last &&
1019
+ last.sendID !== currentUserId
1020
+ ) {
1021
+ requestMarkRead('incoming');
1022
+ }
888
1023
 
889
1024
  // null → a real id is the initial fetch landing (initialScrollAtEnd +
890
1025
  // handleListLoad's job), not a new message; a real id → a different
@@ -899,15 +1034,69 @@ export const ChatListLegend = memo(
899
1034
 
900
1035
  anchorToEndUntilRef.current = Date.now() + NEW_MESSAGE_ANCHOR_MS;
901
1036
  scrollMessageToEnd({ animated: true, closeKeyboard: false });
902
- }, [lastMessageId, scrollMessageToEnd]);
1037
+ }, [
1038
+ lastMessageId,
1039
+ isLoading,
1040
+ scrollMessageToEnd,
1041
+ currentUserId,
1042
+ requestMarkRead,
1043
+ ]);
1044
+
1045
+ // A divider appearing late grows the content under a list resting at the
1046
+ // end, pushing the last message behind the composer. Re-arm the existing
1047
+ // grow → re-aim path in handleScroll; no new scroll call.
1048
+ const prevUnreadRowIdRef = useRef<string | null>(null);
1049
+ useEffect(() => {
1050
+ const prev = prevUnreadRowIdRef.current;
1051
+ prevUnreadRowIdRef.current = unreadAnchorRowId;
1052
+ // Only for a list genuinely resting at the true end — never under a
1053
+ // jump (a deeplink window lands at its end before scrolling away).
1054
+ if (
1055
+ !prev &&
1056
+ unreadAnchorRowId &&
1057
+ isAtBottomRef.current &&
1058
+ !jumpInFlightRef.current &&
1059
+ !hasMoreNew &&
1060
+ listLoadedRef.current
1061
+ ) {
1062
+ anchorToEndUntilRef.current = Date.now() + NEW_MESSAGE_ANCHOR_MS;
1063
+ }
1064
+ }, [unreadAnchorRowId, hasMoreNew]);
1065
+
1066
+ // The list unmounts under the spinner; `handleListLoad` handles the
1067
+ // remount.
1068
+ useEffect(() => {
1069
+ if (isLoading) listLoadedRef.current = false;
1070
+ }, [isLoading]);
903
1071
 
904
1072
  const handleListLoad = useCallback(() => {
905
- if (hasCorrectedInitialPaddingRef.current) return;
1073
+ // Set on every list mount, before the one-shot below.
1074
+ listLoadedRef.current = true;
1075
+ if (hasCorrectedInitialPaddingRef.current) {
1076
+ // A remount after a reload. One that isn't a jump (FAB on a
1077
+ // `hasMoreNew` window, a failed search) lands at the end via
1078
+ // `initialScrollAtEnd`; a jump owns its own landing.
1079
+ requestAnimationFrame(markPositionSettled);
1080
+ if (!jumpInFlightRef.current) {
1081
+ isAtBottomRef.current = true;
1082
+ buttonOpacity.value = withTiming(0, BUTTON_FADE_CONFIG);
1083
+ requestMarkRead('window-reload');
1084
+ }
1085
+ return;
1086
+ }
906
1087
  hasCorrectedInitialPaddingRef.current = true;
907
1088
  perfMark('history_list_loaded');
908
1089
 
909
- scrollMessageToEnd({ animated: false, closeKeyboard: false });
910
- }, [scrollMessageToEnd]);
1090
+ scrollMessageToEnd({ animated: false, closeKeyboard: false }).finally(
1091
+ () => requestAnimationFrame(markPositionSettled)
1092
+ );
1093
+ requestMarkRead('list-load');
1094
+ }, [
1095
+ scrollMessageToEnd,
1096
+ markPositionSettled,
1097
+ requestMarkRead,
1098
+ buttonOpacity,
1099
+ ]);
911
1100
 
912
1101
  useEffect(() => {
913
1102
  const bottomSubscription = DeviceEventEmitter.addListener(
@@ -917,44 +1106,105 @@ export const ChatListLegend = memo(
917
1106
 
918
1107
  const subscription = DeviceEventEmitter.addListener(
919
1108
  ChatEvent.ScrollToMessage,
920
- ({ clientMessageId }: ScrollToMessagePayload) => {
1109
+ ({
1110
+ clientMessageId,
1111
+ highlight = true,
1112
+ viewPosition = 0.5,
1113
+ viewOffset,
1114
+ exitOnUnavailable,
1115
+ reemitAfterSearch,
1116
+ fromSearch,
1117
+ }: ScrollToMessagePayload) => {
1118
+ scrollEventSeqRef.current += 1;
1119
+ // Not in the window: load one around the target.
1120
+ const search = () => {
1121
+ // Already the post-search emit: don't search again (loop guard).
1122
+ if (fromSearch) {
1123
+ setJumpInFlight(false);
1124
+ return;
1125
+ }
1126
+ // The window replace is not "at bottom": without this the
1127
+ // lastMessageId effect and the grow re-aim yank the jump back to
1128
+ // the end.
1129
+ isAtBottomRef.current = false;
1130
+ // Kept in step with the ref: the scroll handler only fades the
1131
+ // button on a change, and it would see none from here on.
1132
+ buttonOpacity.value = withTiming(1, BUTTON_FADE_CONFIG);
1133
+ anchorToEndUntilRef.current = 0;
1134
+ setJumpInFlight(true);
1135
+ const seq = scrollEventSeqRef.current;
1136
+ searchMessages({
1137
+ applicationType,
1138
+ conversationId,
1139
+ startClientMsgId: clientMessageId,
1140
+ scrollOptions: {
1141
+ highlight,
1142
+ viewPosition,
1143
+ viewOffset,
1144
+ exitOnUnavailable,
1145
+ reemitAfterSearch,
1146
+ },
1147
+ }).finally(() => {
1148
+ // Finished without re-emitting (search failed): nothing will
1149
+ // land, so release the flag now rather than after the timeout.
1150
+ // With `reemitAfterSearch: false` the caller owns the landing.
1151
+ if (
1152
+ reemitAfterSearch !== false &&
1153
+ scrollEventSeqRef.current === seq
1154
+ ) {
1155
+ setJumpInFlight(false);
1156
+ }
1157
+ });
1158
+ };
1159
+
1160
+ // Same key as `keyExtractor`, so a server-id-only row is found too.
921
1161
  const index = messagesRef.current.findIndex(
922
- (m) => m.clientMsgID === clientMessageId
1162
+ (m) => (m.clientMsgID || m.serverMsgID) === clientMessageId
923
1163
  );
924
-
925
- if (index >= 0) {
926
- try {
927
- listRef.current
928
- ?.scrollToIndex({
929
- index,
930
- animated: true,
931
- viewPosition: 0.5,
932
- })
933
- .finally(() => {
934
- if (highlightTimerRef.current)
935
- clearTimeout(highlightTimerRef.current);
936
- setHighlightedId(clientMessageId);
937
- highlightTimerRef.current = setTimeout(
938
- () => setHighlightedId(null),
939
- 5000
940
- );
941
- });
942
- } catch {
943
- // scrollToIndex throws if item not rendered yet — force refetch messages
944
- searchMessages({
945
- applicationType,
946
- conversationId,
947
- startClientMsgId: clientMessageId,
948
- });
949
- }
1164
+ if (index < 0) {
1165
+ search();
950
1166
  return;
951
1167
  }
952
1168
 
953
- searchMessages({
954
- applicationType,
955
- conversationId,
956
- startClientMsgId: clientMessageId,
957
- });
1169
+ try {
1170
+ setJumpInFlight(true);
1171
+ // A pending re-aim at the end would pull the jump back down.
1172
+ anchorToEndUntilRef.current = 0;
1173
+ const scrollToTarget = () =>
1174
+ listRef.current?.scrollToIndex({
1175
+ index,
1176
+ animated: true,
1177
+ viewPosition,
1178
+ viewOffset,
1179
+ }) ?? Promise.resolve();
1180
+ scrollToTarget()
1181
+ // An exact landing (`viewOffset`, the unread jump): the first
1182
+ // pass aims with estimated sizes for the rows above, which
1183
+ // undershoots by their error (measured ~250dp over ~20 rows)
1184
+ // and MVCP then holds that spot. Re-aim once they're measured.
1185
+ .then(() =>
1186
+ viewOffset == null
1187
+ ? undefined
1188
+ : new Promise<void>((resolve) =>
1189
+ requestAnimationFrame(() => resolve())
1190
+ ).then(scrollToTarget)
1191
+ )
1192
+ .finally(() => {
1193
+ setJumpInFlight(false);
1194
+ markPositionSettled();
1195
+ if (!highlight) return;
1196
+ if (highlightTimerRef.current)
1197
+ clearTimeout(highlightTimerRef.current);
1198
+ setHighlightedId(clientMessageId);
1199
+ highlightTimerRef.current = setTimeout(
1200
+ () => setHighlightedId(null),
1201
+ 5000
1202
+ );
1203
+ });
1204
+ } catch {
1205
+ // scrollToIndex throws if item not rendered yet — force refetch messages
1206
+ search();
1207
+ }
958
1208
  }
959
1209
  );
960
1210
 
@@ -968,8 +1218,20 @@ export const ChatListLegend = memo(
968
1218
  conversationId,
969
1219
  handleScrollToBottom,
970
1220
  listRef,
1221
+ setJumpInFlight,
1222
+ markPositionSettled,
1223
+ buttonOpacity,
971
1224
  ]);
972
1225
 
1226
+ useEffect(
1227
+ () => () => {
1228
+ if (jumpInFlightTimerRef.current) {
1229
+ clearTimeout(jumpInFlightTimerRef.current);
1230
+ }
1231
+ },
1232
+ []
1233
+ );
1234
+
973
1235
  if (isLoading) {
974
1236
  return (
975
1237
  <KContainer.View flex center background={KColors.white}>
@@ -1032,6 +1294,8 @@ export const ChatListLegend = memo(
1032
1294
  onScroll={handleScroll}
1033
1295
  onScrollBeginDrag={handleScrollBeginDrag}
1034
1296
  onLoad={handleListLoad}
1297
+ onViewableItemsChanged={handleViewableItemsChanged}
1298
+ viewabilityConfig={viewabilityConfig}
1035
1299
  contentContainerStyle={contentContainerStyle}
1036
1300
  scrollIndicatorInsets={{ right: 1 }}
1037
1301
  ListHeaderComponent={
@@ -1049,6 +1313,10 @@ export const ChatListLegend = memo(
1049
1313
  onEndReachedThreshold={0.2}
1050
1314
  initialScrollAtEnd
1051
1315
  />
1316
+ <LegendChatUnreadButton
1317
+ visible={isPillVisible}
1318
+ onPress={onPressUnreadPill}
1319
+ />
1052
1320
  </KContainer.View>
1053
1321
  <Animated.View
1054
1322
  style={[styles.scrollToBottomButton, animatedButtonStyle]}
@@ -0,0 +1,41 @@
1
+ import type { RefObject } from 'react';
2
+ import { FIRST_UNREAD_TIMEOUT_MS } from '../../../services/message';
3
+ import { useUnreadIndicatorStore } from '../../../store/unreadIndicator';
4
+ import {
5
+ getUnreadGateDeadline,
6
+ isUnreadGateClosed,
7
+ } from '../../../utils/unreadIndicatorState';
8
+ import type { MarkReadGate } from './useMarkConversationRead';
9
+
10
+ /**
11
+ * Holds mark-read back until this screen's first-unread lookup has answered,
12
+ * so the thread is never marked read before the anchor is captured. Scoped to
13
+ * the token in `tokenRef`: another screen's session never opens or closes it.
14
+ */
15
+ export const createUnreadMarkReadGate = (
16
+ tokenRef: RefObject<number | null>
17
+ ): MarkReadGate => ({
18
+ isClosed: () =>
19
+ isUnreadGateClosed(
20
+ useUnreadIndicatorStore.getState(),
21
+ tokenRef.current,
22
+ Date.now(),
23
+ FIRST_UNREAD_TIMEOUT_MS
24
+ ),
25
+ openDeadline: () => {
26
+ const s = useUnreadIndicatorStore.getState();
27
+ if (tokenRef.current == null || s.token !== tokenRef.current) return null;
28
+ return s.pending ? getUnreadGateDeadline(s, FIRST_UNREAD_TIMEOUT_MS) : null;
29
+ },
30
+ subscribe: ({ onOpen, onClear }) =>
31
+ useUnreadIndicatorStore.subscribe((next, prev) => {
32
+ if (tokenRef.current == null || next.token !== tokenRef.current) return;
33
+ // A session that just began (token change) is not an open.
34
+ if (prev.token !== next.token) return;
35
+ // `clear` is never a catch-up: it only runs the 'leave' flush.
36
+ if (prev.pending && !next.pending && next.settledBy === 'resolve') {
37
+ onOpen();
38
+ }
39
+ if (prev.settledBy !== 'clear' && next.settledBy === 'clear') onClear();
40
+ }),
41
+ });