@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
|
@@ -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
|
+
};
|