@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.
- package/lib/module/screens/chat-detail/ChatDetail.js +42 -7
- package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js +249 -49
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/hooks/createUnreadMarkReadGate.js +32 -0
- package/lib/module/screens/chat-detail/hooks/createUnreadMarkReadGate.js.map +1 -0
- package/lib/module/screens/chat-detail/hooks/useMarkConversationRead.js +180 -0
- package/lib/module/screens/chat-detail/hooks/useMarkConversationRead.js.map +1 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadAnchorSeen.js +88 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadAnchorSeen.js.map +1 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadDeeplinkCheck.js +59 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadDeeplinkCheck.js.map +1 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadIndicator.js +105 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadIndicator.js.map +1 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadIndicatorInList.js +157 -0
- package/lib/module/screens/chat-detail/hooks/useUnreadIndicatorInList.js.map +1 -0
- package/lib/module/screens/chat-detail/legend/LegendChatUnreadButton.js +140 -0
- package/lib/module/screens/chat-detail/legend/LegendChatUnreadButton.js.map +1 -0
- package/lib/module/screens/chat-detail/legend/LegendChatUnreadDivider.js +51 -0
- package/lib/module/screens/chat-detail/legend/LegendChatUnreadDivider.js.map +1 -0
- package/lib/module/services/message.js +30 -0
- package/lib/module/services/message.js.map +1 -1
- package/lib/module/store/message.js +25 -4
- package/lib/module/store/message.js.map +1 -1
- package/lib/module/store/unreadIndicator.js +26 -0
- package/lib/module/store/unreadIndicator.js.map +1 -0
- package/lib/module/translation/resources/i18n.js +2 -0
- package/lib/module/translation/resources/i18n.js.map +1 -1
- package/lib/module/types/chat.js.map +1 -1
- package/lib/module/types/unreadIndicator.js +2 -0
- package/lib/module/types/unreadIndicator.js.map +1 -0
- package/lib/module/utils/markReadPolicy.js +25 -0
- package/lib/module/utils/markReadPolicy.js.map +1 -0
- package/lib/module/utils/promise.js +16 -0
- package/lib/module/utils/promise.js.map +1 -1
- package/lib/module/utils/unreadAnchor.js +57 -0
- package/lib/module/utils/unreadAnchor.js.map +1 -0
- package/lib/module/utils/unreadIndicatorState.js +96 -0
- package/lib/module/utils/unreadIndicatorState.js.map +1 -0
- package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +4 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/hooks/createUnreadMarkReadGate.d.ts +9 -0
- package/lib/typescript/src/screens/chat-detail/hooks/createUnreadMarkReadGate.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useMarkConversationRead.d.ts +39 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useMarkConversationRead.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadAnchorSeen.d.ts +26 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadAnchorSeen.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadDeeplinkCheck.d.ts +17 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadDeeplinkCheck.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicator.d.ts +21 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicator.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicatorInList.d.ts +35 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useUnreadIndicatorInList.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadButton.d.ts +8 -0
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadButton.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadDivider.d.ts +7 -0
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatUnreadDivider.d.ts.map +1 -0
- package/lib/typescript/src/services/message.d.ts +8 -0
- package/lib/typescript/src/services/message.d.ts.map +1 -1
- package/lib/typescript/src/store/message.d.ts.map +1 -1
- package/lib/typescript/src/store/unreadIndicator.d.ts +10 -0
- package/lib/typescript/src/store/unreadIndicator.d.ts.map +1 -0
- package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
- package/lib/typescript/src/types/chat.d.ts +3 -0
- package/lib/typescript/src/types/chat.d.ts.map +1 -1
- package/lib/typescript/src/types/events.d.ts +17 -1
- package/lib/typescript/src/types/events.d.ts.map +1 -1
- package/lib/typescript/src/types/unreadIndicator.d.ts +45 -0
- package/lib/typescript/src/types/unreadIndicator.d.ts.map +1 -0
- package/lib/typescript/src/utils/markReadPolicy.d.ts +27 -0
- package/lib/typescript/src/utils/markReadPolicy.d.ts.map +1 -0
- package/lib/typescript/src/utils/promise.d.ts +6 -0
- package/lib/typescript/src/utils/promise.d.ts.map +1 -1
- package/lib/typescript/src/utils/unreadAnchor.d.ts +33 -0
- package/lib/typescript/src/utils/unreadAnchor.d.ts.map +1 -0
- package/lib/typescript/src/utils/unreadIndicatorState.d.ts +26 -0
- package/lib/typescript/src/utils/unreadIndicatorState.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/screens/chat-detail/ChatDetail.tsx +58 -5
- package/src/screens/chat-detail/ChatListLegend.tsx +314 -46
- package/src/screens/chat-detail/hooks/createUnreadMarkReadGate.ts +41 -0
- package/src/screens/chat-detail/hooks/useMarkConversationRead.ts +197 -0
- package/src/screens/chat-detail/hooks/useUnreadAnchorSeen.ts +105 -0
- package/src/screens/chat-detail/hooks/useUnreadDeeplinkCheck.ts +68 -0
- package/src/screens/chat-detail/hooks/useUnreadIndicator.ts +112 -0
- package/src/screens/chat-detail/hooks/useUnreadIndicatorInList.ts +181 -0
- package/src/screens/chat-detail/legend/LegendChatUnreadButton.tsx +165 -0
- package/src/screens/chat-detail/legend/LegendChatUnreadDivider.tsx +52 -0
- package/src/services/message.ts +42 -0
- package/src/store/message.ts +23 -5
- package/src/store/unreadIndicator.ts +38 -0
- package/src/translation/resources/i18n.ts +2 -0
- package/src/types/chat.ts +3 -0
- package/src/types/events.ts +18 -1
- package/src/types/unreadIndicator.ts +49 -0
- package/src/utils/markReadPolicy.ts +55 -0
- package/src/utils/promise.ts +20 -0
- package/src/utils/unreadAnchor.ts +87 -0
- 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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
({
|
|
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
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
+
});
|