@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,165 @@
1
+ import { memo, useEffect } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import Animated, {
4
+ Easing,
5
+ cancelAnimation,
6
+ useAnimatedStyle,
7
+ useReducedMotion,
8
+ useSharedValue,
9
+ withDelay,
10
+ withRepeat,
11
+ withSequence,
12
+ withTiming,
13
+ } from 'react-native-reanimated';
14
+ import {
15
+ KColors,
16
+ KContainer,
17
+ KImage,
18
+ KLabel,
19
+ KRadiusValue,
20
+ } from '@droppii/libs';
21
+ import { trans } from '../../../translation';
22
+
23
+ const FADE = { duration: 200, easing: Easing.out(Easing.ease) };
24
+
25
+ // Chevron cascade (D5): the bottom chevron leads and the top one follows, an
26
+ // upward motion cue. Designer may tune these.
27
+ const CYCLE_MS = 1200;
28
+ const STEP_MS = CYCLE_MS / 4;
29
+ const DIM = 0.35;
30
+ const CHEVRON_SIZE = 14;
31
+
32
+ // The touch target is real padded bounds, not `hitSlop`: a hitSlop reaching
33
+ // outside the parent Animated.View is dead on Android. 23 + 2 * 11 = 45pt.
34
+ const TOUCH_PAD_V = 11;
35
+ const TOUCH_PAD_L = 8;
36
+ // The pill itself sits 16 below the list top.
37
+ const PILL_TOP = 16;
38
+
39
+ const wave = () =>
40
+ withRepeat(
41
+ withSequence(
42
+ withTiming(DIM, { duration: STEP_MS }),
43
+ withTiming(1, { duration: STEP_MS })
44
+ ),
45
+ -1
46
+ );
47
+
48
+ const UnreadChevrons = memo(() => {
49
+ const reducedMotion = useReducedMotion();
50
+ const top = useSharedValue(1);
51
+ const bottom = useSharedValue(1);
52
+
53
+ useEffect(() => {
54
+ if (reducedMotion) return;
55
+ bottom.value = wave();
56
+ top.value = withDelay(STEP_MS, wave());
57
+ return () => {
58
+ cancelAnimation(top);
59
+ cancelAnimation(bottom);
60
+ };
61
+ }, [reducedMotion, top, bottom]);
62
+
63
+ const topStyle = useAnimatedStyle(() => ({ opacity: top.value }));
64
+ const bottomStyle = useAnimatedStyle(() => ({ opacity: bottom.value }));
65
+
66
+ return (
67
+ <KContainer.View style={styles.chevrons}>
68
+ <Animated.View style={topStyle}>
69
+ <KImage.VectorIcons
70
+ name="angle-up-o"
71
+ size={CHEVRON_SIZE}
72
+ color={KColors.palette.gray.w900}
73
+ />
74
+ </Animated.View>
75
+ <Animated.View style={[styles.chevronOverlap, bottomStyle]}>
76
+ <KImage.VectorIcons
77
+ name="angle-up-o"
78
+ size={CHEVRON_SIZE}
79
+ color={KColors.palette.gray.w900}
80
+ />
81
+ </Animated.View>
82
+ </KContainer.View>
83
+ );
84
+ });
85
+
86
+ UnreadChevrons.displayName = 'UnreadChevrons';
87
+
88
+ interface LegendChatUnreadButtonProps {
89
+ visible: boolean;
90
+ onPress?: () => void;
91
+ }
92
+
93
+ /** "Tin chưa đọc" half-pill, top-right of the list; jumps to the divider. */
94
+ export const LegendChatUnreadButton = memo(
95
+ ({ visible, onPress }: LegendChatUnreadButtonProps) => {
96
+ const opacity = useSharedValue(0);
97
+
98
+ useEffect(() => {
99
+ opacity.value = withTiming(visible ? 1 : 0, FADE);
100
+ }, [visible, opacity]);
101
+
102
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
103
+
104
+ return (
105
+ <Animated.View
106
+ style={[styles.anchor, animatedStyle]}
107
+ // From the prop, not the animated opacity: deterministic on Old Arch.
108
+ pointerEvents={visible ? 'box-none' : 'none'}
109
+ >
110
+ <KContainer.Touchable
111
+ onPress={onPress}
112
+ activeOpacity={0.7}
113
+ style={styles.touchArea}
114
+ accessible
115
+ accessibilityRole="button"
116
+ accessibilityLabel={trans('chat:unread_jump_button')}
117
+ >
118
+ <KContainer.View style={styles.pill}>
119
+ {/* Mounted only while visible: a hidden pill runs no animation. */}
120
+ {visible && <UnreadChevrons />}
121
+ <KLabel.Text typo="TextXsMedium" color={KColors.palette.gray.w900}>
122
+ {trans('chat:unread_jump_button')}
123
+ </KLabel.Text>
124
+ </KContainer.View>
125
+ </KContainer.Touchable>
126
+ </Animated.View>
127
+ );
128
+ }
129
+ );
130
+
131
+ LegendChatUnreadButton.displayName = 'LegendChatUnreadButton';
132
+
133
+ const styles = StyleSheet.create({
134
+ anchor: {
135
+ position: 'absolute',
136
+ top: PILL_TOP - TOUCH_PAD_V,
137
+ right: 0,
138
+ zIndex: 10,
139
+ },
140
+ touchArea: {
141
+ paddingVertical: TOUCH_PAD_V,
142
+ paddingLeft: TOUCH_PAD_L,
143
+ },
144
+ pill: {
145
+ flexDirection: 'row',
146
+ alignItems: 'center',
147
+ gap: 2,
148
+ paddingHorizontal: 6,
149
+ paddingVertical: 2,
150
+ backgroundColor: KColors.palette.grayCool.w25,
151
+ borderWidth: 1,
152
+ borderRightWidth: 0,
153
+ borderColor: KColors.white,
154
+ borderTopLeftRadius: KRadiusValue.round,
155
+ borderBottomLeftRadius: KRadiusValue.round,
156
+ },
157
+ chevrons: {
158
+ width: CHEVRON_SIZE,
159
+ height: CHEVRON_SIZE,
160
+ justifyContent: 'center',
161
+ },
162
+ chevronOverlap: {
163
+ marginTop: -8,
164
+ },
165
+ });
@@ -0,0 +1,52 @@
1
+ import { memo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import {
4
+ KColors,
5
+ KContainer,
6
+ KDivider,
7
+ KLabel,
8
+ KSpacingValue,
9
+ } from '@droppii/libs';
10
+ import { trans } from '../../../translation';
11
+
12
+ const LINE_COLOR = KColors.opacity.grayDark[10];
13
+
14
+ /**
15
+ * "TIN NHẮN CHƯA ĐỌC" row above the first unread message. Figma spacing: the
16
+ * previous row's 8 bottom margin + 8 top margin + 4 padding = 20 above the
17
+ * lines; 4 padding + 12 bottom margin + the time label's 8 = 24 below.
18
+ */
19
+ export const LegendChatUnreadDivider = memo(() => (
20
+ <KContainer.View
21
+ row
22
+ center
23
+ paddingH="1rem"
24
+ paddingV="0.25rem"
25
+ style={styles.root}
26
+ accessible
27
+ accessibilityLabel={trans('chat:unread_divider')}
28
+ >
29
+ <KContainer.View flex>
30
+ <KDivider type="line" size="hairline" background={LINE_COLOR} />
31
+ </KContainer.View>
32
+ <KLabel.Text
33
+ typo="TextXsBold"
34
+ color={KColors.palette.gray.w200}
35
+ marginH="1rem"
36
+ >
37
+ {trans('chat:unread_divider')}
38
+ </KLabel.Text>
39
+ <KContainer.View flex>
40
+ <KDivider type="line" size="hairline" background={LINE_COLOR} />
41
+ </KContainer.View>
42
+ </KContainer.View>
43
+ ));
44
+
45
+ LegendChatUnreadDivider.displayName = 'LegendChatUnreadDivider';
46
+
47
+ const styles = StyleSheet.create({
48
+ root: {
49
+ marginTop: KSpacingValue['0.5rem'],
50
+ marginBottom: KSpacingValue['0.75rem'],
51
+ },
52
+ });
@@ -5,9 +5,16 @@ import OpenIMSDK, {
5
5
  } from '@droppii/openim-rn-client-sdk';
6
6
  import { useConversationStore } from '../store';
7
7
  import { getReceiverId } from '../utils/message';
8
+ import { perfMark } from '../utils/perf-marker';
9
+ import { withTimeout } from '../utils/promise';
8
10
 
9
11
  const DEFAULT_PAGE_SIZE = 20;
10
12
 
13
+ // Rendering never waits on this, only the read receipt does, while a timeout
14
+ // loses the indicator for good. Tune from the measured p95 of
15
+ // `unread_anchor_response.ms` before release.
16
+ export const FIRST_UNREAD_TIMEOUT_MS = 5000;
17
+
11
18
  export type HistoryMessageResult = AdvancedGetMessageResult & {
12
19
  lastMinSeq?: number;
13
20
  };
@@ -78,6 +85,41 @@ export namespace ChatMessageAPI {
78
85
  ]);
79
86
  }
80
87
 
88
+ /**
89
+ * First unread message of the conversation, or `null` when there is none,
90
+ * the call fails, or it times out. Never throws. The server records the
91
+ * conversation's open time on this call, so callers invoke it once per
92
+ * visit.
93
+ */
94
+ export async function getFirstUnreadMessage(
95
+ conversationID: string,
96
+ timeoutMs = FIRST_UNREAD_TIMEOUT_MS
97
+ ): Promise<MessageItem | null> {
98
+ const startedAt = Date.now();
99
+ try {
100
+ perfMark('unread_anchor_request');
101
+ const res = await withTimeout(
102
+ OpenIMSDK.getFirstUnreadMessage(conversationID),
103
+ timeoutMs
104
+ );
105
+ perfMark('unread_anchor_response', {
106
+ found: !!res?.message,
107
+ ms: Date.now() - startedAt,
108
+ });
109
+ return res?.message ?? null;
110
+ } catch (error) {
111
+ try {
112
+ perfMark('unread_anchor_response', {
113
+ found: false,
114
+ failed: true,
115
+ ms: Date.now() - startedAt,
116
+ });
117
+ } catch {}
118
+ if (__DEV__) console.log('[unread] getFirstUnreadMessage failed', error);
119
+ return null;
120
+ }
121
+ }
122
+
81
123
  export async function getCurrentUserId() {
82
124
  return OpenIMSDK.getLoginUserID();
83
125
  }
@@ -96,6 +96,8 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
96
96
  ? mergeMessages(state.messages, res.messageList || [])
97
97
  : res.messageList || [],
98
98
  hasMoreOld: !res.isEnd,
99
+ // A fresh fetch is the newest page (FAB out of a jump window).
100
+ ...(loadMore ? {} : { hasMoreNew: false }),
99
101
  initLoading: false,
100
102
  moreOldLoading: false,
101
103
  }));
@@ -153,8 +155,11 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
153
155
  applicationType,
154
156
  conversationId,
155
157
  startClientMsgId,
158
+ scrollOptions,
156
159
  }: ISearchMessagesParams) => {
157
160
  if (!conversationId || !startClientMsgId) return;
161
+ // Opted out of the toast-and-exit: the caller handles a missing target.
162
+ const keepOnUnavailable = scrollOptions?.exitOnUnavailable === false;
158
163
  try {
159
164
  set({ initLoading: true });
160
165
  const searchPrevParams = {
@@ -200,6 +205,17 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
200
205
  ],
201
206
  []
202
207
  );
208
+ const targetMessage = newMessages.find(
209
+ (msg) => msg.clientMsgID === startClientMsgId
210
+ );
211
+
212
+ // Not in the local DB: keep the current window rather than swapping
213
+ // in one around a target that isn't there.
214
+ if (!targetMessage && keepOnUnavailable) {
215
+ set({ initLoading: false });
216
+ return;
217
+ }
218
+
203
219
  set({
204
220
  messages: newMessages,
205
221
  hasMoreOld: !searchPrevRes.isEnd,
@@ -207,18 +223,20 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
207
223
  initLoading: false,
208
224
  });
209
225
 
210
- const targetMessage = newMessages.find(
211
- (msg) => msg.clientMsgID === startClientMsgId
212
- );
213
-
214
226
  if (!targetMessage || isMessageRevoked(targetMessage)) {
215
- DeviceEventEmitter.emit(ChatEvent.TargetMessageUnavailable);
227
+ if (!keepOnUnavailable) {
228
+ DeviceEventEmitter.emit(ChatEvent.TargetMessageUnavailable);
229
+ }
216
230
  return;
217
231
  }
218
232
 
233
+ if (scrollOptions?.reemitAfterSearch === false) return;
234
+
219
235
  await new Promise((resolve) => setTimeout(resolve, 300));
220
236
  DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
237
+ ...scrollOptions,
221
238
  clientMessageId: startClientMsgId,
239
+ fromSearch: true,
222
240
  });
223
241
  }
224
242
  } catch (error) {
@@ -0,0 +1,38 @@
1
+ import { create } from 'zustand';
2
+ import type { UnreadIndicatorStore } from '../types/unreadIndicator';
3
+ import {
4
+ INITIAL_UNREAD_INDICATOR_STATE,
5
+ beginUnreadSession,
6
+ clearUnreadSession,
7
+ confirmUnreadDeeplinkCheck,
8
+ dismissUnreadPill,
9
+ rearmUnreadDeeplinkCheck,
10
+ resetUnreadSession,
11
+ resolveUnreadSession,
12
+ } from '../utils/unreadIndicatorState';
13
+
14
+ /**
15
+ * Unread-indicator session for ChatDetail. A dedicated store rather than a
16
+ * slice of `useMessageStore`, whose `reset()` also runs on a deeplink target
17
+ * change. Module scope on purpose: the session outlives remounts of
18
+ * ChatDetail inside the same route (access re-check, SDK reconnect). Internal —
19
+ * not exported from `store/index.ts`.
20
+ */
21
+ export const useUnreadIndicatorStore = create<UnreadIndicatorStore>()(
22
+ (set, get) => ({
23
+ ...INITIAL_UNREAD_INDICATOR_STATE,
24
+ begin: (options) => {
25
+ set((s) => beginUnreadSession(s, options, Date.now()));
26
+ return get().token;
27
+ },
28
+ resolve: (token, anchor) =>
29
+ set((s) => resolveUnreadSession(s, token, anchor)),
30
+ clear: (token) => set((s) => clearUnreadSession(s, token)),
31
+ dismissPill: (token) => set((s) => dismissUnreadPill(s, token)),
32
+ confirmDeeplinkCheck: (token, hide) =>
33
+ set((s) => confirmUnreadDeeplinkCheck(s, token, hide)),
34
+ rearmDeeplinkCheck: (token) =>
35
+ set((s) => rearmUnreadDeeplinkCheck(s, token)),
36
+ reset: () => set((s) => resetUnreadSession(s)),
37
+ })
38
+ );
@@ -39,6 +39,8 @@ const i18nKey: Record<string, string> = {
39
39
  'chat:copy_message_link_success': 'Sao chép đường dẫn tin nhắn thành công',
40
40
  'chat:target_message_unavailable':
41
41
  'Tin nhắn có thể đã bị xoá và không còn tồn tại',
42
+ 'chat:unread_divider': 'TIN NHẮN CHƯA ĐỌC',
43
+ 'chat:unread_jump_button': 'Tin chưa đọc',
42
44
  'chat:retry_message': 'Gửi lại',
43
45
  'chat:revoke_message': 'Thu hồi',
44
46
  'chat:pin_message': 'Ghim',
package/src/types/chat.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  IRunConversationFlowPayload,
17
17
  } from './flows';
18
18
  import type { GetOpenIMTokenResponse } from './auth';
19
+ import type { ScrollToMessageOptions } from './events';
19
20
  import type { ChatDetailProps } from '../screens/chat-detail';
20
21
 
21
22
  export const EventProvider = {
@@ -213,6 +214,8 @@ export interface ISearchMessagesParams {
213
214
  applicationType: DChatApplicationType;
214
215
  conversationId?: string;
215
216
  startClientMsgId: string;
217
+ /** Carried from the ScrollToMessage that triggered the search. */
218
+ scrollOptions?: ScrollToMessageOptions;
216
219
  }
217
220
 
218
221
  export interface MessageStore {
@@ -9,8 +9,25 @@ export enum ChatEvent {
9
9
  TargetMessageUnavailable = 'chat:target_message_unavailable',
10
10
  }
11
11
 
12
- export interface ScrollToMessagePayload {
12
+ export interface ScrollToMessageOptions {
13
+ /** Default true: highlight the target for 5s after landing. */
14
+ highlight?: boolean;
15
+ /** Default 0.5 (centred). */
16
+ viewPosition?: number;
17
+ viewOffset?: number;
18
+ /** Default true: an unavailable target toasts and navigates back. */
19
+ exitOnUnavailable?: boolean;
20
+ /**
21
+ * Default true. False: an out-of-window jump loads the window but doesn't
22
+ * re-emit, because the caller re-resolves its own target.
23
+ */
24
+ reemitAfterSearch?: boolean;
25
+ }
26
+
27
+ export interface ScrollToMessagePayload extends ScrollToMessageOptions {
13
28
  clientMessageId: string;
29
+ /** @internal Set on post-search emits; stops a re-search loop. */
30
+ fromSearch?: boolean;
14
31
  }
15
32
 
16
33
  export interface MessageRevokedPayload {
@@ -0,0 +1,49 @@
1
+ // Internal to the library (not re-exported from `src/index.tsx`).
2
+
3
+ /** The validated first-unread message of a thread. */
4
+ export interface UnreadAnchor {
5
+ clientMsgID: string;
6
+ seq: number;
7
+ }
8
+
9
+ /**
10
+ * One unread-indicator session per route visit. `token` identifies the
11
+ * session: every mutation after `begin` names the token it was issued for and
12
+ * is a no-op otherwise, so two ChatDetail instances of the same thread can't
13
+ * touch each other's session.
14
+ */
15
+ export interface UnreadIndicatorState {
16
+ token: number;
17
+ routeKey?: string;
18
+ conversationId?: string;
19
+ /** The `getFirstUnreadMessage` lookup is in flight (mark-read gate). */
20
+ pending: boolean;
21
+ pendingSince: number;
22
+ anchor: UnreadAnchor | null;
23
+ pillDismissed: boolean;
24
+ /** Deeplink entry: hide the indicator until the D11 rule is decided. */
25
+ awaitingDeeplinkCheck: boolean;
26
+ /**
27
+ * How the lookup phase ended: `resolve` (answered), `clear` (left the
28
+ * screen — blur), `hide` (D11 deeplink rule hid it; the user is still here).
29
+ */
30
+ settledBy: 'resolve' | 'clear' | 'hide' | null;
31
+ }
32
+
33
+ export interface BeginUnreadSessionOptions {
34
+ routeKey: string;
35
+ conversationId: string;
36
+ /** Bot threads: no lookup, no indicator, gate open. */
37
+ skip: boolean;
38
+ awaitDeeplinkCheck: boolean;
39
+ }
40
+
41
+ export interface UnreadIndicatorStore extends UnreadIndicatorState {
42
+ begin: (options: BeginUnreadSessionOptions) => number;
43
+ resolve: (token: number, anchor: UnreadAnchor | null) => void;
44
+ clear: (token: number) => void;
45
+ dismissPill: (token: number) => void;
46
+ confirmDeeplinkCheck: (token: number, hide: boolean) => void;
47
+ rearmDeeplinkCheck: (token: number) => void;
48
+ reset: () => void;
49
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * When ChatDetail may mark the conversation read. Since OpenIM rc43 every
3
+ * call is a server round-trip (two SDK requests), so it's edge-triggered and
4
+ * throttled instead of fired on every scroll event. Pure, for unit tests.
5
+ */
6
+
7
+ export type MarkReadReason =
8
+ | 'list-load'
9
+ | 'reached-bottom'
10
+ | 'incoming'
11
+ | 'fab'
12
+ | 'window-reload'
13
+ | 'focus'
14
+ | 'app-active'
15
+ | 'gate-open'
16
+ | 'leave';
17
+
18
+ export interface MarkReadInput {
19
+ reason: MarkReadReason;
20
+ /** The unread lookup hasn't answered yet (its anchor must be captured first). */
21
+ gateClosed: boolean;
22
+ focused: boolean;
23
+ appActive: boolean;
24
+ listLoaded: boolean;
25
+ /** The window isn't at the newest message (deeplink / jump window). */
26
+ hasMoreNew: boolean;
27
+ atBottom: boolean;
28
+ isDeeplinkFirstLoad: boolean;
29
+ /** A mark-read became due while the screen was focused. */
30
+ dirtyWhileFocused: boolean;
31
+ }
32
+
33
+ /** A list (re)load just landed — not something the user did. */
34
+ export const isListLoadReason = (reason: MarkReadReason): boolean =>
35
+ reason === 'list-load' || reason === 'window-reload';
36
+
37
+ export const shouldRequestMarkRead = (input: MarkReadInput): boolean => {
38
+ const atTrueEnd = input.listLoaded && !input.hasMoreNew && input.atBottom;
39
+ // The user saw it and is leaving: gate and focus no longer matter, but a
40
+ // message that only arrived while blurred must not be marked read.
41
+ if (input.reason === 'leave') return atTrueEnd && input.dirtyWhileFocused;
42
+ if (isListLoadReason(input.reason) && input.isDeeplinkFirstLoad) {
43
+ return false;
44
+ }
45
+ return !input.gateClosed && input.focused && input.appActive && atTrueEnd;
46
+ };
47
+
48
+ export const MARK_READ_THROTTLE_MS = 1000;
49
+
50
+ /** 0 → call now; otherwise the delay of the trailing call. */
51
+ export const nextMarkReadDelay = (
52
+ now: number,
53
+ lastCallAt: number,
54
+ throttleMs = MARK_READ_THROTTLE_MS
55
+ ): number => Math.max(0, lastCallAt + throttleMs - now);
@@ -16,3 +16,23 @@ export const allSettled = <T extends readonly unknown[]>(
16
16
  .catch((reason) => ({ status: 'rejected' as const, reason }))
17
17
  )
18
18
  ) as Promise<{ [K in keyof T]: SettledResult<Awaited<T[K]>> }>;
19
+
20
+ /**
21
+ * Rejects with `Error('timeout')` if `promise` hasn't settled within `ms`.
22
+ * A settle that lands after the timeout is ignored, and the timer is always
23
+ * cleared on settle so nothing is left pending.
24
+ */
25
+ export const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> =>
26
+ new Promise<T>((resolve, reject) => {
27
+ const timer = setTimeout(() => reject(new Error('timeout')), ms);
28
+ promise.then(
29
+ (value) => {
30
+ clearTimeout(timer);
31
+ resolve(value);
32
+ },
33
+ (error) => {
34
+ clearTimeout(timer);
35
+ reject(error);
36
+ }
37
+ );
38
+ });
@@ -0,0 +1,87 @@
1
+ import type { MessageItem } from '@droppii/openim-rn-client-sdk';
2
+ import type { UnreadAnchor } from '../types/unreadIndicator';
3
+ import {
4
+ getGroupIdFromConversationId,
5
+ isConversationParticipant,
6
+ isSingleConversationId,
7
+ } from './conversation';
8
+
9
+ /** Height of the divider at the top of the anchor row (Figma: 27px). */
10
+ export const UNREAD_DIVIDER_HEIGHT = 27;
11
+
12
+ export const messageBelongsToConversation = (
13
+ message: Pick<MessageItem, 'sendID' | 'recvID' | 'groupID'>,
14
+ conversationId: string
15
+ ): boolean =>
16
+ isSingleConversationId(conversationId)
17
+ ? isConversationParticipant(conversationId, message.sendID) &&
18
+ isConversationParticipant(conversationId, message.recvID)
19
+ : !!message.groupID &&
20
+ getGroupIdFromConversationId(conversationId) === message.groupID;
21
+
22
+ /**
23
+ * Validates the server's first-unread message before it's trusted: a finite
24
+ * positive `seq`, a non-empty `clientMsgID`, and a message that belongs to
25
+ * this conversation. Anything else → `null` (no indicator).
26
+ */
27
+ export const toUnreadAnchor = (
28
+ message: MessageItem | null | undefined,
29
+ conversationId: string
30
+ ): UnreadAnchor | null => {
31
+ if (!message) return null;
32
+ const { clientMsgID, seq } = message;
33
+ if (typeof clientMsgID !== 'string' || !clientMsgID) return null;
34
+ if (typeof seq !== 'number' || !Number.isFinite(seq) || seq <= 0) {
35
+ return null;
36
+ }
37
+ if (!messageBelongsToConversation(message, conversationId)) return null;
38
+ return { clientMsgID, seq };
39
+ };
40
+
41
+ /**
42
+ * The list row that carries the divider: the first RENDERED message at or
43
+ * after the anchor. The anchor itself may be filtered out of the rendered list
44
+ * (internal / bot notification messages), so the match is by `seq` as well as
45
+ * by id. Only valid when the boundary is inside the loaded window — the raw
46
+ * window's oldest `seq` is at or before the anchor, or there's no older
47
+ * history — otherwise `null` (the anchor is out of window).
48
+ * Returns the row's list key (same as `keyExtractor` for real messages).
49
+ */
50
+ export const resolveUnreadAnchorRowId = (
51
+ rendered: readonly MessageItem[],
52
+ anchor: UnreadAnchor | null,
53
+ window: { oldestLoadedSeq: number; hasMoreOld: boolean }
54
+ ): string | null => {
55
+ if (!anchor || rendered.length === 0) return null;
56
+ const boundaryLoaded =
57
+ !window.hasMoreOld ||
58
+ (window.oldestLoadedSeq > 0 && window.oldestLoadedSeq <= anchor.seq);
59
+ if (!boundaryLoaded) return null;
60
+ const row = rendered.find(
61
+ (m) =>
62
+ m.clientMsgID === anchor.clientMsgID || (m.seq > 0 && m.seq >= anchor.seq)
63
+ );
64
+ return row ? row.clientMsgID || row.serverMsgID || null : null;
65
+ };
66
+
67
+ /**
68
+ * Whether the top edge of a row (where the divider sits) is inside the
69
+ * viewport with at least `minVisiblePx` of it showing. A tall row clipped at
70
+ * the top — only its bottom part visible — does NOT count.
71
+ */
72
+ export const isRowTopInViewport = (
73
+ rowTop: number | undefined,
74
+ scroll: number,
75
+ scrollLength: number,
76
+ minVisiblePx = UNREAD_DIVIDER_HEIGHT
77
+ ): boolean =>
78
+ rowTop != null &&
79
+ rowTop >= scroll &&
80
+ rowTop + minVisiblePx <= scroll + scrollLength;
81
+
82
+ /** D11: a deeplink to a message older than the first unread hides the indicator. */
83
+ export const isDeeplinkTargetBeforeAnchor = (
84
+ targetSeq?: number,
85
+ anchorSeq?: number
86
+ ): boolean =>
87
+ !!targetSeq && !!anchorSeq && targetSeq > 0 && targetSeq < anchorSeq;