@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,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
|
+
});
|
package/src/services/message.ts
CHANGED
|
@@ -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
|
}
|
package/src/store/message.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/types/events.ts
CHANGED
|
@@ -9,8 +9,25 @@ export enum ChatEvent {
|
|
|
9
9
|
TargetMessageUnavailable = 'chat:target_message_unavailable',
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface
|
|
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);
|
package/src/utils/promise.ts
CHANGED
|
@@ -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;
|