@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
@@ -0,0 +1,197 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from 'react';
2
+ import { AppState } from 'react-native';
3
+ import { useNavigation } from '@react-navigation/native';
4
+ import { ChatMessageAPI } from '../../../services/message';
5
+ import { perfMark } from '../../../utils/perf-marker';
6
+ import {
7
+ isListLoadReason,
8
+ nextMarkReadDelay,
9
+ shouldRequestMarkRead,
10
+ type MarkReadReason,
11
+ } from '../../../utils/markReadPolicy';
12
+
13
+ /**
14
+ * Something mark-read has to wait for. Optional, so the edge-triggered
15
+ * mark-read works on its own (gate open) without the unread indicator.
16
+ */
17
+ export interface MarkReadGate {
18
+ isClosed: () => boolean;
19
+ /** When a closed gate opens on its own (epoch ms), or null if it won't. */
20
+ openDeadline: () => number | null;
21
+ /** `onOpen`: the gate opened; `onClear`: the session ended (non-child blur). */
22
+ subscribe: (handlers: {
23
+ onOpen: () => void;
24
+ onClear: () => void;
25
+ }) => () => void;
26
+ }
27
+
28
+ /**
29
+ * Edge-triggered, throttled mark-read. A trigger marks the conversation dirty
30
+ * and tries to flush; the flush only calls the SDK when `shouldRequestMarkRead`
31
+ * allows it, at most once per `MARK_READ_THROTTLE_MS` (trailing). The dirty
32
+ * flag is cleared only by a successful call, so a suppressed or failed one is
33
+ * retried by the next trigger. Everything is read through refs, so the
34
+ * returned callbacks are stable.
35
+ *
36
+ * Deeplink entry: the first list load of each deeplink target never marks the
37
+ * thread dirty — the list rests at the window end only until the jump runs.
38
+ */
39
+ export const useMarkConversationRead = ({
40
+ conversationId,
41
+ isAtBottomRef,
42
+ listLoadedRef,
43
+ hasMoreNew,
44
+ deeplinkTargetId,
45
+ gate,
46
+ }: {
47
+ conversationId?: string;
48
+ isAtBottomRef: RefObject<boolean>;
49
+ listLoadedRef: RefObject<boolean>;
50
+ hasMoreNew: boolean;
51
+ deeplinkTargetId?: string;
52
+ gate?: MarkReadGate;
53
+ }) => {
54
+ const navigation = useNavigation();
55
+ const dirtyRef = useRef(false);
56
+ const dirtyWhileFocusedRef = useRef(false);
57
+ const lastCallAtRef = useRef(0);
58
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
59
+ const timerDueRef = useRef(0);
60
+ // The next list load is a deeplink's first (pending the jump).
61
+ const deeplinkLoadHeldRef = useRef(!!deeplinkTargetId);
62
+ const latest = useRef({ conversationId, hasMoreNew, gate });
63
+ latest.current = { conversationId, hasMoreNew, gate };
64
+ const tryFlushRef = useRef<(reason: MarkReadReason) => void>(() => {});
65
+
66
+ const clearTimer = () => {
67
+ clearTimeout(timerRef.current);
68
+ timerRef.current = undefined;
69
+ };
70
+
71
+ // Single timer: a sooner deadline replaces a later one; the timer
72
+ // re-evaluates everything when it fires.
73
+ const schedule = (delay: number, reason: MarkReadReason) => {
74
+ const due = Date.now() + delay;
75
+ if (timerRef.current && timerDueRef.current <= due) return;
76
+ clearTimer();
77
+ timerDueRef.current = due;
78
+ timerRef.current = setTimeout(() => {
79
+ timerRef.current = undefined;
80
+ tryFlushRef.current(reason);
81
+ }, delay);
82
+ };
83
+
84
+ const callMarkRead = () => {
85
+ const id = latest.current.conversationId;
86
+ if (!id) return;
87
+ const wasDirtyWhileFocused = dirtyWhileFocusedRef.current;
88
+ lastCallAtRef.current = Date.now();
89
+ dirtyRef.current = false;
90
+ dirtyWhileFocusedRef.current = false;
91
+ // At the SDK call site: 1 marker = 2 SDK requests.
92
+ perfMark('mark_read_call');
93
+ ChatMessageAPI.markConversationAsRead(id).catch((error) => {
94
+ dirtyRef.current = true; // retried by the next trigger
95
+ dirtyWhileFocusedRef.current ||= wasDirtyWhileFocused;
96
+ if (__DEV__) console.log('[mark-read] failed', error);
97
+ });
98
+ };
99
+
100
+ tryFlushRef.current = (reason: MarkReadReason) => {
101
+ if (!dirtyRef.current) return;
102
+ const { hasMoreNew: moreNew, gate: currentGate } = latest.current;
103
+ const gateClosed = !!currentGate?.isClosed();
104
+ const allowed = shouldRequestMarkRead({
105
+ reason,
106
+ gateClosed,
107
+ focused: navigation.isFocused(),
108
+ appActive: AppState.currentState === 'active',
109
+ listLoaded: !!listLoadedRef.current,
110
+ hasMoreNew: moreNew,
111
+ atBottom: !!isAtBottomRef.current,
112
+ isDeeplinkFirstLoad: false, // held loads never reach a flush
113
+ dirtyWhileFocused: dirtyWhileFocusedRef.current,
114
+ });
115
+ if (!allowed) {
116
+ // Fail-safe: re-check when the gate's watchdog opens it.
117
+ const deadline = gateClosed ? currentGate?.openDeadline() : null;
118
+ if (deadline != null) {
119
+ schedule(Math.max(0, deadline - Date.now()) + 1, 'gate-open');
120
+ }
121
+ return;
122
+ }
123
+ // Bypass the throttle: the component is going away.
124
+ if (reason === 'leave') return callMarkRead();
125
+ const delay = nextMarkReadDelay(Date.now(), lastCallAtRef.current);
126
+ if (delay === 0) return callMarkRead();
127
+ schedule(delay, reason);
128
+ };
129
+
130
+ /** A trigger: something may now be read. */
131
+ const requestMarkRead = useCallback(
132
+ (reason: MarkReadReason) => {
133
+ if (isListLoadReason(reason) && deeplinkLoadHeldRef.current) {
134
+ deeplinkLoadHeldRef.current = false;
135
+ return;
136
+ }
137
+ dirtyRef.current = true;
138
+ if (navigation.isFocused()) dirtyWhileFocusedRef.current = true;
139
+ tryFlushRef.current(reason);
140
+ },
141
+ [navigation]
142
+ );
143
+
144
+ /** Flush-only: retry a pending mark-read without marking anything new. */
145
+ const flushMarkRead = useCallback((reason: MarkReadReason) => {
146
+ tryFlushRef.current(reason);
147
+ }, []);
148
+
149
+ // A new conversation / deeplink target on the same instance starts over.
150
+ useEffect(() => {
151
+ dirtyRef.current = false;
152
+ dirtyWhileFocusedRef.current = false;
153
+ deeplinkLoadHeldRef.current = !!deeplinkTargetId;
154
+ clearTimer();
155
+ }, [conversationId, deeplinkTargetId]);
156
+
157
+ // Back on the screen (after a blur) / back in the app: messages may have
158
+ // arrived meanwhile. The initial focus is not a return.
159
+ useEffect(() => {
160
+ let hasBlurred = false;
161
+ const offBlur = navigation.addListener('blur', () => {
162
+ hasBlurred = true;
163
+ });
164
+ const offFocus = navigation.addListener('focus', () => {
165
+ if (hasBlurred) requestMarkRead('focus');
166
+ });
167
+ return () => {
168
+ offBlur();
169
+ offFocus();
170
+ };
171
+ }, [navigation, requestMarkRead]);
172
+
173
+ useEffect(() => {
174
+ const sub = AppState.addEventListener('change', (state) => {
175
+ if (state === 'active') requestMarkRead('app-active');
176
+ });
177
+ return () => sub.remove();
178
+ }, [requestMarkRead]);
179
+
180
+ useEffect(() => {
181
+ if (!gate) return;
182
+ return gate.subscribe({
183
+ onOpen: () => tryFlushRef.current('gate-open'),
184
+ onClear: () => tryFlushRef.current('leave'),
185
+ });
186
+ }, [gate]);
187
+
188
+ useEffect(
189
+ () => () => {
190
+ clearTimer();
191
+ tryFlushRef.current('leave');
192
+ },
193
+ []
194
+ );
195
+
196
+ return { requestMarkRead, flushMarkRead };
197
+ };
@@ -0,0 +1,105 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type RefObject,
7
+ } from 'react';
8
+ import type {
9
+ LegendListRef,
10
+ ViewToken,
11
+ ViewabilityConfig,
12
+ } from '@legendapp/list/react-native';
13
+ import { useUnreadIndicatorStore } from '../../../store/unreadIndicator';
14
+ import { isRowTopInViewport } from '../../../utils/unreadAnchor';
15
+
16
+ // A candidate filter only; `isRowTopInViewport` decides.
17
+ const VIEWABILITY_CONFIG: ViewabilityConfig = {
18
+ itemVisiblePercentThreshold: 1,
19
+ };
20
+
21
+ /**
22
+ * "The divider has been seen" = the anchor row's TOP edge (where the divider
23
+ * sits) is in the viewport, after initial positioning has settled. Before
24
+ * settle — Old Arch corrects the estimated `initialScrollAtEnd` later — a
25
+ * visible divider only hides the pill (render-time check), it doesn't dismiss
26
+ * it for good.
27
+ */
28
+ export const useUnreadAnchorSeen = ({
29
+ rowId,
30
+ unreadToken,
31
+ isLoading,
32
+ listRef,
33
+ }: {
34
+ rowId: string | null;
35
+ unreadToken: number | null;
36
+ isLoading: boolean;
37
+ listRef: RefObject<LegendListRef | null>;
38
+ }) => {
39
+ const rowIdRef = useRef(rowId);
40
+ rowIdRef.current = rowId;
41
+ const tokenRef = useRef(unreadToken);
42
+ tokenRef.current = unreadToken;
43
+ const viewableKeysRef = useRef<Set<string>>(new Set());
44
+ const settledRef = useRef(false);
45
+ const [positionSettled, setPositionSettled] = useState(false);
46
+ const [viewabilityReady, setViewabilityReady] = useState(false);
47
+
48
+ const isAnchorTopVisibleNow = useCallback(() => {
49
+ const id = rowIdRef.current;
50
+ if (!id || !viewableKeysRef.current.has(id)) return false;
51
+ const state = listRef.current?.getState?.();
52
+ if (!state) return false;
53
+ const rowTop = state.positionByKey(id);
54
+ return isRowTopInViewport(rowTop, state.scroll, state.scrollLength);
55
+ }, [listRef]);
56
+
57
+ const checkAnchorSeen = useCallback(() => {
58
+ if (!settledRef.current || tokenRef.current == null) return;
59
+ if (!isAnchorTopVisibleNow()) return;
60
+ useUnreadIndicatorStore.getState().dismissPill(tokenRef.current);
61
+ }, [isAnchorTopVisibleNow]);
62
+
63
+ // Stable identity: LegendList reads it once.
64
+ const handleViewableItemsChanged = useCallback(
65
+ ({ viewableItems }: { viewableItems: ViewToken[] }) => {
66
+ viewableKeysRef.current = new Set(viewableItems.map((t) => t.key));
67
+ setViewabilityReady(true);
68
+ checkAnchorSeen();
69
+ },
70
+ [checkAnchorSeen]
71
+ );
72
+
73
+ // handleListLoad's scrollToEnd + 1 frame | first drag | a finished jump.
74
+ const markPositionSettled = useCallback(() => {
75
+ if (!settledRef.current) {
76
+ settledRef.current = true;
77
+ setPositionSettled(true);
78
+ }
79
+ checkAnchorSeen(); // the CURRENT snapshot, not an older one
80
+ }, [checkAnchorSeen]);
81
+
82
+ // Late anchor / a new window.
83
+ useEffect(() => {
84
+ checkAnchorSeen();
85
+ }, [rowId, checkAnchorSeen]);
86
+
87
+ // The list remounts under the spinner: start over.
88
+ useEffect(() => {
89
+ if (!isLoading) return;
90
+ viewableKeysRef.current = new Set();
91
+ settledRef.current = false;
92
+ setPositionSettled(false);
93
+ setViewabilityReady(false);
94
+ }, [isLoading]);
95
+
96
+ return {
97
+ positionSettled,
98
+ viewabilityReady,
99
+ viewabilityConfig: VIEWABILITY_CONFIG,
100
+ isAnchorTopVisibleNow,
101
+ checkAnchorSeen,
102
+ handleViewableItemsChanged,
103
+ markPositionSettled,
104
+ };
105
+ };
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef, type RefObject } from 'react';
2
+ import { useUnreadIndicatorStore } from '../../../store/unreadIndicator';
3
+ import { useMessageStore } from '../../../store/message';
4
+ import { isDeeplinkTargetBeforeAnchor } from '../../../utils/unreadAnchor';
5
+
6
+ /**
7
+ * D11: a deeplink to a message OLDER than the first unread hides the
8
+ * indicator; a deeplink at/after it (or whose target never shows up) shows it.
9
+ * Decided explicitly once the lookup has answered and the window has loaded,
10
+ * and re-decided — against the existing anchor, with no SDK call — when
11
+ * `targetMessageId` changes on the same screen.
12
+ *
13
+ * Must run above `useMessages` in ChatDetail: the re-arm effect has to land
14
+ * before `useMessages`' `reset()` in the same commit.
15
+ */
16
+ export const useUnreadDeeplinkCheck = ({
17
+ targetMessageId,
18
+ unreadToken,
19
+ tokenRef,
20
+ }: {
21
+ targetMessageId?: string;
22
+ unreadToken: number | null;
23
+ tokenRef: RefObject<number | null>;
24
+ }) => {
25
+ const awaiting = useUnreadIndicatorStore(
26
+ (s) =>
27
+ unreadToken != null && s.token === unreadToken && s.awaitingDeeplinkCheck
28
+ );
29
+ const pending = useUnreadIndicatorStore((s) => s.pending);
30
+ const initLoading = useMessageStore((s) => s.initLoading);
31
+ const rawMessages = useMessageStore((s) => s.messages);
32
+ const prevTargetRef = useRef(targetMessageId);
33
+ // After a re-arm, wait until the new search has been seen loading and then
34
+ // finished, so the rule is never decided against the old window.
35
+ const needLoadCycleRef = useRef(false);
36
+
37
+ useEffect(() => {
38
+ const prev = prevTargetRef.current;
39
+ prevTargetRef.current = targetMessageId;
40
+ const token = tokenRef.current;
41
+ if (!targetMessageId || targetMessageId === prev || token == null) return;
42
+ const store = useUnreadIndicatorStore.getState();
43
+ if (store.token !== token || !store.anchor) return;
44
+ store.rearmDeeplinkCheck(token);
45
+ needLoadCycleRef.current = true;
46
+ }, [targetMessageId, tokenRef]);
47
+
48
+ useEffect(() => {
49
+ const token = tokenRef.current;
50
+ if (!targetMessageId || !awaiting || pending || token == null) return;
51
+ if (needLoadCycleRef.current) {
52
+ if (initLoading) needLoadCycleRef.current = false;
53
+ return;
54
+ }
55
+ if (initLoading) return;
56
+ const store = useUnreadIndicatorStore.getState();
57
+ if (!store.anchor) {
58
+ store.confirmDeeplinkCheck(token, false);
59
+ return;
60
+ }
61
+ const target = rawMessages.find((m) => m.clientMsgID === targetMessageId);
62
+ // A missing target (search failed / not in window) shows normally.
63
+ store.confirmDeeplinkCheck(
64
+ token,
65
+ isDeeplinkTargetBeforeAnchor(target?.seq, store.anchor.seq)
66
+ );
67
+ }, [targetMessageId, awaiting, pending, initLoading, rawMessages, tokenRef]);
68
+ };
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ useIsFocused,
4
+ useNavigation,
5
+ useRoute,
6
+ } from '@react-navigation/native';
7
+ import { ChatMessageAPI } from '../../../services/message';
8
+ import { useUnreadIndicatorStore } from '../../../store/unreadIndicator';
9
+ import { toUnreadAnchor } from '../../../utils/unreadAnchor';
10
+ import { useUnreadDeeplinkCheck } from './useUnreadDeeplinkCheck';
11
+
12
+ // A blur within this long of a library-initiated child navigation (Thread
13
+ // Info, in-thread search, add member) keeps the indicator; any other blur
14
+ // (tab switch, another module) clears it.
15
+ export const CHILD_NAV_TTL_MS = 1000;
16
+
17
+ /**
18
+ * Owns this screen's unread-indicator session. `getFirstUnreadMessage` records
19
+ * the thread's open time server-side, so it's called exactly once per route
20
+ * visit: on the first focus after access is confirmed. A remount of ChatDetail
21
+ * inside the same route (access re-check, SDK reconnect) adopts the existing
22
+ * session instead of calling again; a new route key is a new visit.
23
+ *
24
+ * Must be called above `useMessages` (see `useUnreadDeeplinkCheck`).
25
+ */
26
+ export const useUnreadIndicator = ({
27
+ conversationId,
28
+ isBotConversation,
29
+ targetMessageId,
30
+ accessConfirmed,
31
+ }: {
32
+ conversationId: string;
33
+ isBotConversation: boolean;
34
+ targetMessageId?: string;
35
+ accessConfirmed: boolean;
36
+ }) => {
37
+ const routeKey = useRoute().key;
38
+ const navigation = useNavigation();
39
+ const isFocused = useIsFocused();
40
+ // Ref for listeners and async code; state so token-scoped selectors re-run
41
+ // when it's set (adopting doesn't change the store).
42
+ const tokenRef = useRef<number | null>(null);
43
+ const [unreadToken, setUnreadToken] = useState<number | null>(null);
44
+ const childNavAtRef = useRef(0);
45
+
46
+ useEffect(() => {
47
+ if (!conversationId) return;
48
+ const s = useUnreadIndicatorStore.getState();
49
+ if (s.routeKey === routeKey && s.conversationId === conversationId) {
50
+ tokenRef.current = s.token;
51
+ setUnreadToken(s.token);
52
+ return;
53
+ }
54
+ if (!isFocused || !accessConfirmed) return;
55
+ const token = s.begin({
56
+ routeKey,
57
+ conversationId,
58
+ skip: isBotConversation,
59
+ awaitDeeplinkCheck: !!targetMessageId,
60
+ });
61
+ tokenRef.current = token;
62
+ setUnreadToken(token);
63
+ if (isBotConversation) return;
64
+ let resolved = false;
65
+ (async () => {
66
+ try {
67
+ const message =
68
+ await ChatMessageAPI.getFirstUnreadMessage(conversationId);
69
+ useUnreadIndicatorStore
70
+ .getState()
71
+ .resolve(token, toUnreadAnchor(message, conversationId));
72
+ resolved = true;
73
+ } catch (error) {
74
+ if (__DEV__) console.log('[unread] resolve failed', error);
75
+ } finally {
76
+ // The mark-read gate must always open.
77
+ if (!resolved) useUnreadIndicatorStore.getState().resolve(token, null);
78
+ }
79
+ })();
80
+ // targetMessageId is deliberately not a dependency: a target change on
81
+ // the same screen re-arms D11, it doesn't start a new visit.
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, [conversationId, routeKey, isFocused, accessConfirmed, isBotConversation]);
84
+
85
+ const markChildNavigation = useCallback(() => {
86
+ childNavAtRef.current = Date.now();
87
+ }, []);
88
+
89
+ useEffect(() => {
90
+ const offBlur = navigation.addListener('blur', () => {
91
+ const isChildNavigation =
92
+ Date.now() - childNavAtRef.current < CHILD_NAV_TTL_MS;
93
+ childNavAtRef.current = 0;
94
+ const token = tokenRef.current;
95
+ // Token-scoped: a no-op if another visit has superseded this session.
96
+ if (!isChildNavigation && token != null) {
97
+ useUnreadIndicatorStore.getState().clear(token);
98
+ }
99
+ });
100
+ const offFocus = navigation.addListener('focus', () => {
101
+ childNavAtRef.current = 0;
102
+ });
103
+ return () => {
104
+ offBlur();
105
+ offFocus();
106
+ };
107
+ }, [navigation]);
108
+
109
+ useUnreadDeeplinkCheck({ targetMessageId, unreadToken, tokenRef });
110
+
111
+ return { unreadToken, unreadTokenRef: tokenRef, markChildNavigation };
112
+ };
@@ -0,0 +1,181 @@
1
+ import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
2
+ import { DeviceEventEmitter } from 'react-native';
3
+ import type { MessageItem } from '@droppii/openim-rn-client-sdk';
4
+ import type { LegendListRef } from '@legendapp/list/react-native';
5
+ import { useUnreadIndicatorStore } from '../../../store/unreadIndicator';
6
+ import { useMessageStore } from '../../../store/message';
7
+ import { ChatEvent, type ScrollToMessageOptions } from '../../../types/events';
8
+ import { resolveUnreadAnchorRowId } from '../../../utils/unreadAnchor';
9
+ import { useUnreadAnchorSeen } from './useUnreadAnchorSeen';
10
+
11
+ /** Lands the divider ~100px below the list top, no highlight, never exits. */
12
+ export const UNREAD_JUMP_OPTIONS: ScrollToMessageOptions = {
13
+ highlight: false,
14
+ viewPosition: 0,
15
+ viewOffset: 100,
16
+ exitOnUnavailable: false,
17
+ };
18
+ // Parity with searchMessages' own re-emit delay.
19
+ const POST_SEARCH_SCROLL_DELAY_MS = 300;
20
+ // Anchor not reachable by id (not in the local DB): page older history at
21
+ // most this many times (~75 messages) looking for the boundary.
22
+ const MAX_ANCHOR_PAGES = 5;
23
+
24
+ type PendingJump = 'idle' | 'requested' | 'loading' | 'paging';
25
+
26
+ /**
27
+ * Wires the unread session into the list: which row carries the divider,
28
+ * whether the pill shows, and the pill's jump. The session is token-scoped —
29
+ * a second ChatDetail of the same thread never reads or dismisses this one's.
30
+ */
31
+ export const useUnreadIndicatorInList = ({
32
+ messages,
33
+ unreadToken,
34
+ isLoading,
35
+ hasMoreOld,
36
+ moreOldLoading,
37
+ onLoadMoreOld,
38
+ isBotConversation,
39
+ listRef,
40
+ jumpInFlightRef,
41
+ setJumpInFlight,
42
+ }: {
43
+ messages: MessageItem[];
44
+ unreadToken: number | null;
45
+ isLoading: boolean;
46
+ hasMoreOld: boolean;
47
+ moreOldLoading: boolean;
48
+ onLoadMoreOld?: (loadMore: boolean) => void;
49
+ isBotConversation: boolean;
50
+ listRef: RefObject<LegendListRef | null>;
51
+ jumpInFlightRef: RefObject<boolean>;
52
+ /** Writes the flag (and its safety timeout); `jumpInFlightRef` reads it. */
53
+ setJumpInFlight: (inFlight: boolean) => void;
54
+ }) => {
55
+ const own = useUnreadIndicatorStore(
56
+ (s) => unreadToken != null && s.token === unreadToken
57
+ );
58
+ const anchor = useUnreadIndicatorStore((s) => s.anchor);
59
+ const pillDismissed = useUnreadIndicatorStore((s) => s.pillDismissed);
60
+ const awaiting = useUnreadIndicatorStore((s) => s.awaitingDeeplinkCheck);
61
+ // Raw window (before rendering filters) decides whether the boundary is in.
62
+ const oldestLoadedSeq = useMessageStore((s) => s.messages[0]?.seq ?? 0);
63
+ const active = own && !isBotConversation && !awaiting && !!anchor;
64
+
65
+ const unreadAnchorRowId = useMemo(
66
+ () =>
67
+ active
68
+ ? resolveUnreadAnchorRowId(messages, anchor, {
69
+ oldestLoadedSeq,
70
+ hasMoreOld,
71
+ })
72
+ : null,
73
+ [active, messages, anchor, oldestLoadedSeq, hasMoreOld]
74
+ );
75
+
76
+ const seen = useUnreadAnchorSeen({
77
+ rowId: unreadAnchorRowId,
78
+ unreadToken,
79
+ isLoading,
80
+ listRef,
81
+ });
82
+
83
+ const latest = useRef({ unreadAnchorRowId, unreadToken, hasMoreOld });
84
+ latest.current = { unreadAnchorRowId, unreadToken, hasMoreOld };
85
+ const onLoadMoreOldRef = useRef(onLoadMoreOld);
86
+ onLoadMoreOldRef.current = onLoadMoreOld;
87
+ const pendingJumpRef = useRef<PendingJump>('idle');
88
+ const pagesRef = useRef(0);
89
+
90
+ // After the window has landed (or been kept): land on the row re-resolved
91
+ // by seq, page older history toward it, or give up silently (no toast).
92
+ const landOrPage = useCallback(() => {
93
+ const { unreadAnchorRowId: id, hasMoreOld: more } = latest.current;
94
+ if (id) {
95
+ pendingJumpRef.current = 'idle';
96
+ DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
97
+ ...UNREAD_JUMP_OPTIONS,
98
+ clientMessageId: id,
99
+ fromSearch: true,
100
+ });
101
+ return;
102
+ }
103
+ if (
104
+ more &&
105
+ pagesRef.current < MAX_ANCHOR_PAGES &&
106
+ onLoadMoreOldRef.current
107
+ ) {
108
+ pagesRef.current += 1;
109
+ pendingJumpRef.current = 'paging';
110
+ onLoadMoreOldRef.current(true);
111
+ return;
112
+ }
113
+ pendingJumpRef.current = 'idle';
114
+ setJumpInFlight(false);
115
+ }, [setJumpInFlight]);
116
+
117
+ useEffect(() => {
118
+ if (isLoading) {
119
+ if (pendingJumpRef.current === 'requested') {
120
+ pendingJumpRef.current = 'loading';
121
+ }
122
+ return;
123
+ }
124
+ if (pendingJumpRef.current !== 'loading') return;
125
+ const timer = setTimeout(landOrPage, POST_SEARCH_SCROLL_DELAY_MS);
126
+ return () => clearTimeout(timer);
127
+ }, [isLoading, landOrPage]);
128
+
129
+ // One older page landed while paging toward the anchor.
130
+ const prevMoreOldLoadingRef = useRef(moreOldLoading);
131
+ useEffect(() => {
132
+ const wasLoading = prevMoreOldLoadingRef.current;
133
+ prevMoreOldLoadingRef.current = moreOldLoading;
134
+ if (wasLoading && !moreOldLoading && pendingJumpRef.current === 'paging') {
135
+ landOrPage();
136
+ }
137
+ }, [moreOldLoading, landOrPage]);
138
+
139
+ const jumpToFirstUnread = useCallback(() => {
140
+ const s = useUnreadIndicatorStore.getState();
141
+ const { unreadToken: token, unreadAnchorRowId: id } = latest.current;
142
+ if (token == null || s.token !== token || !s.anchor) return;
143
+ if (s.pillDismissed || jumpInFlightRef.current) return;
144
+ s.dismissPill(token);
145
+ setJumpInFlight(true);
146
+ if (id) {
147
+ DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
148
+ ...UNREAD_JUMP_OPTIONS,
149
+ clientMessageId: id,
150
+ });
151
+ return;
152
+ }
153
+ pendingJumpRef.current = 'requested';
154
+ pagesRef.current = 0;
155
+ DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
156
+ ...UNREAD_JUMP_OPTIONS,
157
+ clientMessageId: s.anchor.clientMsgID,
158
+ reemitAfterSearch: false,
159
+ });
160
+ }, [jumpInFlightRef, setJumpInFlight]);
161
+
162
+ // Evaluated in render, including "the divider is on screen right now", so a
163
+ // late anchor never flashes the pill for a frame.
164
+ const isPillVisible =
165
+ active &&
166
+ !pillDismissed &&
167
+ !isLoading &&
168
+ seen.positionSettled &&
169
+ seen.viewabilityReady &&
170
+ !(unreadAnchorRowId && seen.isAnchorTopVisibleNow());
171
+
172
+ return {
173
+ unreadAnchorRowId,
174
+ isPillVisible,
175
+ viewabilityConfig: seen.viewabilityConfig,
176
+ handleViewableItemsChanged: seen.handleViewableItemsChanged,
177
+ checkAnchorSeen: seen.checkAnchorSeen,
178
+ markPositionSettled: seen.markPositionSettled,
179
+ jumpToFirstUnread,
180
+ };
181
+ };