@droppii-org/chat-mobile 0.2.105 → 0.2.107
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/components/TypingDots/TypingDots.js +131 -0
- package/lib/module/components/TypingDots/TypingDots.js.map +1 -0
- package/lib/module/components/TypingDots/index.js +4 -0
- package/lib/module/components/TypingDots/index.js.map +1 -0
- package/lib/module/components/messages/ExpandableMessageText.js +112 -0
- package/lib/module/components/messages/ExpandableMessageText.js.map +1 -0
- package/lib/module/components/messages/PreviewScrollableText.js +80 -0
- package/lib/module/components/messages/PreviewScrollableText.js.map +1 -0
- package/lib/module/components/messages/linkMessage/index.js +21 -12
- package/lib/module/components/messages/linkMessage/index.js.map +1 -1
- package/lib/module/components/messages/quotedMessage/index.js +12 -2
- package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
- package/lib/module/components/messages/textMessage/index.js +7 -1
- package/lib/module/components/messages/textMessage/index.js.map +1 -1
- package/lib/module/components/messages/types.js.map +1 -1
- package/lib/module/core/useChatListener.js +26 -2
- package/lib/module/core/useChatListener.js.map +1 -1
- package/lib/module/hooks/groups/useGroupMemberName.js +32 -0
- package/lib/module/hooks/groups/useGroupMemberName.js.map +1 -0
- package/lib/module/hooks/query-keys.js +2 -1
- package/lib/module/hooks/query-keys.js.map +1 -1
- package/lib/module/hooks/useExpandableText.js +120 -0
- package/lib/module/hooks/useExpandableText.js.map +1 -0
- package/lib/module/hooks/useInputState.js +38 -0
- package/lib/module/hooks/useInputState.js.map +1 -0
- package/lib/module/hooks/useInputStateBroadcast.js +53 -0
- package/lib/module/hooks/useInputStateBroadcast.js.map +1 -0
- package/lib/module/screens/chat-detail/ChatComposer.js +15 -3
- package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatDetail.js +11 -0
- package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js +2 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatTypingIndicator.js +86 -0
- package/lib/module/screens/chat-detail/ChatTypingIndicator.js.map +1 -0
- package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +7 -0
- package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
- package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +10 -3
- package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +11 -2
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +79 -6
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js +30 -0
- package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js.map +1 -0
- package/lib/module/store/expandedMessage.js +28 -0
- package/lib/module/store/expandedMessage.js.map +1 -0
- package/lib/module/store/index.js +1 -0
- package/lib/module/store/index.js.map +1 -1
- package/lib/module/store/inputState.js +64 -0
- package/lib/module/store/inputState.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/utils/expandableText.js +41 -0
- package/lib/module/utils/expandableText.js.map +1 -0
- package/lib/module/utils/inlineTruncation.js +51 -0
- package/lib/module/utils/inlineTruncation.js.map +1 -0
- package/lib/module/utils/inputState.js +77 -0
- package/lib/module/utils/inputState.js.map +1 -0
- package/lib/module/utils/message.js +9 -0
- package/lib/module/utils/message.js.map +1 -1
- package/lib/module/utils/truncateTextChildren.js +38 -0
- package/lib/module/utils/truncateTextChildren.js.map +1 -0
- package/lib/typescript/src/components/TypingDots/TypingDots.d.ts +14 -0
- package/lib/typescript/src/components/TypingDots/TypingDots.d.ts.map +1 -0
- package/lib/typescript/src/components/TypingDots/index.d.ts +3 -0
- package/lib/typescript/src/components/TypingDots/index.d.ts.map +1 -0
- package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts +25 -0
- package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts.map +1 -0
- package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts +29 -0
- package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts.map +1 -0
- package/lib/typescript/src/components/messages/linkMessage/index.d.ts +1 -1
- package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/textMessage/index.d.ts +1 -1
- package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/types.d.ts +18 -0
- package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
- package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
- package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts +10 -0
- package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts.map +1 -0
- package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
- package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useExpandableText.d.ts +13 -0
- package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useInputState.d.ts +21 -0
- package/lib/typescript/src/hooks/useInputState.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts +14 -0
- package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts +8 -0
- package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +11 -1
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +14 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts +18 -0
- package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts.map +1 -0
- package/lib/typescript/src/store/expandedMessage.d.ts +15 -0
- package/lib/typescript/src/store/expandedMessage.d.ts.map +1 -0
- package/lib/typescript/src/store/index.d.ts +1 -0
- package/lib/typescript/src/store/index.d.ts.map +1 -1
- package/lib/typescript/src/store/inputState.d.ts +18 -0
- package/lib/typescript/src/store/inputState.d.ts.map +1 -0
- package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
- package/lib/typescript/src/utils/expandableText.d.ts +18 -0
- package/lib/typescript/src/utils/expandableText.d.ts.map +1 -0
- package/lib/typescript/src/utils/inlineTruncation.d.ts +24 -0
- package/lib/typescript/src/utils/inlineTruncation.d.ts.map +1 -0
- package/lib/typescript/src/utils/inputState.d.ts +54 -0
- package/lib/typescript/src/utils/inputState.d.ts.map +1 -0
- package/lib/typescript/src/utils/message.d.ts +8 -0
- package/lib/typescript/src/utils/message.d.ts.map +1 -1
- package/lib/typescript/src/utils/truncateTextChildren.d.ts +3 -0
- package/lib/typescript/src/utils/truncateTextChildren.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/TypingDots/TypingDots.tsx +172 -0
- package/src/components/TypingDots/index.ts +2 -0
- package/src/components/messages/ExpandableMessageText.tsx +145 -0
- package/src/components/messages/PreviewScrollableText.tsx +98 -0
- package/src/components/messages/linkMessage/index.tsx +30 -21
- package/src/components/messages/quotedMessage/index.tsx +14 -2
- package/src/components/messages/textMessage/index.tsx +11 -2
- package/src/components/messages/types.ts +18 -0
- package/src/core/useChatListener.ts +35 -1
- package/src/hooks/groups/useGroupMemberName.ts +31 -0
- package/src/hooks/query-keys.ts +2 -0
- package/src/hooks/useExpandableText.ts +139 -0
- package/src/hooks/useInputState.ts +57 -0
- package/src/hooks/useInputStateBroadcast.ts +53 -0
- package/src/screens/chat-detail/ChatComposer.tsx +17 -2
- package/src/screens/chat-detail/ChatDetail.tsx +11 -0
- package/src/screens/chat-detail/ChatListLegend.tsx +2 -0
- package/src/screens/chat-detail/ChatTypingIndicator.tsx +89 -0
- package/src/screens/chat-detail/PinnedMessagesSheet.tsx +8 -0
- package/src/screens/chat-detail/legend/LegendChatMessage.tsx +22 -2
- package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -1
- package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +111 -5
- package/src/screens/chat-detail/messageToolbox/useClonePreview.ts +34 -0
- package/src/store/expandedMessage.ts +42 -0
- package/src/store/index.ts +1 -0
- package/src/store/inputState.ts +76 -0
- package/src/translation/resources/i18n.ts +2 -0
- package/src/utils/expandableText.ts +51 -0
- package/src/utils/inlineTruncation.ts +65 -0
- package/src/utils/inputState.ts +104 -0
- package/src/utils/message.ts +15 -0
- package/src/utils/truncateTextChildren.ts +46 -0
|
@@ -29,6 +29,7 @@ import { useSendMessage } from '../../../hooks/message/useSendMessage';
|
|
|
29
29
|
import { useChatContext, useChatDetailContext } from '../../../context';
|
|
30
30
|
import {
|
|
31
31
|
GroupPermission,
|
|
32
|
+
MessageType,
|
|
32
33
|
type MessageItem,
|
|
33
34
|
} from '@droppii/openim-rn-client-sdk';
|
|
34
35
|
import { useHasGroupPermission } from '../../../hooks/useGroupPermission';
|
|
@@ -44,6 +45,13 @@ import type { GroupPosition } from '../../../utils/legendListMessage';
|
|
|
44
45
|
// scrollTo. See `show`/`hide` below for the observed sequence.
|
|
45
46
|
const UNFREEZE_DELAY_MS = 300;
|
|
46
47
|
|
|
48
|
+
const EXPANDABLE_TYPES: number[] = [
|
|
49
|
+
MessageType.TextMessage,
|
|
50
|
+
MessageType.AtTextMessage,
|
|
51
|
+
MessageType.UrlTextMessage,
|
|
52
|
+
MessageType.QuoteMessage,
|
|
53
|
+
];
|
|
54
|
+
|
|
47
55
|
interface MessageToolboxOverlayProps {
|
|
48
56
|
currentUserId?: string;
|
|
49
57
|
customMessageItemFactory?: (item: MessageItem) => ReactNode;
|
|
@@ -209,11 +217,19 @@ export const MessageToolboxOverlay = forwardRef<
|
|
|
209
217
|
hide();
|
|
210
218
|
}, [message, hide, setQuotedMessage]);
|
|
211
219
|
|
|
220
|
+
const overlayMessageId = message?.clientMsgID || message?.serverMsgID || '';
|
|
221
|
+
|
|
212
222
|
if (!visible || !message || !rect) {
|
|
213
223
|
return null;
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
const isOutgoing = message.sendID === currentUserId;
|
|
227
|
+
// Only the types that can show `Xem thêm` get a touchable clone. File
|
|
228
|
+
// download and consumer custom nodes carry their own handlers and stay
|
|
229
|
+
// inert.
|
|
230
|
+
const isCloneInteractive =
|
|
231
|
+
EXPANDABLE_TYPES.includes(message.contentType) &&
|
|
232
|
+
!customMessageItemFactory?.(message);
|
|
217
233
|
const showSenderGutter = isGroupConversation && !isOutgoing;
|
|
218
234
|
const showName =
|
|
219
235
|
showSenderGutter &&
|
|
@@ -235,10 +251,13 @@ export const MessageToolboxOverlay = forwardRef<
|
|
|
235
251
|
<MessageToolboxWrapper
|
|
236
252
|
rect={rect}
|
|
237
253
|
isOutgoing={isOutgoing}
|
|
254
|
+
isCloneInteractive={isCloneInteractive}
|
|
255
|
+
onDismiss={hide}
|
|
238
256
|
message={
|
|
239
257
|
<LegendChatMessage
|
|
240
258
|
message={message}
|
|
241
|
-
messageId={
|
|
259
|
+
messageId={overlayMessageId}
|
|
260
|
+
isPreview
|
|
242
261
|
isOutgoing={isOutgoing}
|
|
243
262
|
createdAtTime={message.sendTime || message.createTime}
|
|
244
263
|
customMessageItemFactory={customMessageItemFactory}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { useCallback, memo } from 'react';
|
|
1
|
+
import { useCallback, memo, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
|
+
ScrollView,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
4
7
|
Platform,
|
|
5
8
|
useWindowDimensions,
|
|
6
9
|
type LayoutChangeEvent,
|
|
@@ -15,13 +18,31 @@ import type { MeasuredRect } from './types';
|
|
|
15
18
|
import { messageStyles } from '../legend/messageTypes';
|
|
16
19
|
import Animated from 'react-native-reanimated';
|
|
17
20
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
21
|
+
import { ClonePreviewBudgetContext } from '../../../components/messages/PreviewScrollableText';
|
|
22
|
+
import { useClonePreview } from './useClonePreview';
|
|
18
23
|
|
|
19
24
|
const MENU_GAP = 8;
|
|
25
|
+
// A clone is never squeezed below this, even if the toolbox is unusually tall —
|
|
26
|
+
// better to overflow slightly than to render a sliver of a message.
|
|
27
|
+
const MIN_CLONE_HEIGHT = 120;
|
|
20
28
|
|
|
21
29
|
type MessageToolboxWrapperProps = {
|
|
22
30
|
isOutgoing?: boolean;
|
|
23
31
|
rect: MeasuredRect;
|
|
24
32
|
message?: React.ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Lets touches reach the clone's own press targets — needed for `Xem thêm`
|
|
35
|
+
* (D1). Off by default: types like FileMessage and consumer custom nodes
|
|
36
|
+
* have self-contained handlers that must stay inert here.
|
|
37
|
+
*/
|
|
38
|
+
isCloneInteractive?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Tapping the bubble used to fall through to the backdrop and dismiss. The
|
|
41
|
+
* ScrollView below is hit-testable and swallows that, so the gesture is
|
|
42
|
+
* reinstated explicitly. A touch that a press target inside the clone
|
|
43
|
+
* claims (`Xem thêm`) never reaches it.
|
|
44
|
+
*/
|
|
45
|
+
onDismiss?: () => void;
|
|
25
46
|
reaction?: React.ReactNode;
|
|
26
47
|
toolbox?: React.ReactNode;
|
|
27
48
|
};
|
|
@@ -31,6 +52,8 @@ export const MessageToolboxWrapper = memo(
|
|
|
31
52
|
isOutgoing,
|
|
32
53
|
rect,
|
|
33
54
|
message,
|
|
55
|
+
isCloneInteractive,
|
|
56
|
+
onDismiss,
|
|
34
57
|
reaction,
|
|
35
58
|
toolbox,
|
|
36
59
|
}: MessageToolboxWrapperProps) => {
|
|
@@ -41,6 +64,14 @@ export const MessageToolboxWrapper = memo(
|
|
|
41
64
|
const topOffset = Platform.OS === 'android' ? insets.top : 0;
|
|
42
65
|
const { height: windowHeight } = useWindowDimensions();
|
|
43
66
|
|
|
67
|
+
// Mirrors `toolboxHeight` on the JS side. Deriving the clone's maxHeight
|
|
68
|
+
// from the shared value instead would close a loop — maxHeight feeds the
|
|
69
|
+
// clone's height, which feeds `messageHeight`, which feeds `translateY`.
|
|
70
|
+
// The toolbox's own height does not depend on the clone, so reading it
|
|
71
|
+
// here settles in one pass. Not ratcheted: the menu's item set varies with
|
|
72
|
+
// permissions, and a high-water mark could never shrink back.
|
|
73
|
+
const [chromeHeight, setChromeHeight] = useState(0);
|
|
74
|
+
|
|
44
75
|
const onMessageCloneLayout = useCallback(
|
|
45
76
|
({ nativeEvent }: LayoutChangeEvent) => {
|
|
46
77
|
messageHeight.value = nativeEvent.layout.height;
|
|
@@ -51,6 +82,7 @@ export const MessageToolboxWrapper = memo(
|
|
|
51
82
|
const onToolboxLayout = useCallback(
|
|
52
83
|
({ nativeEvent }: LayoutChangeEvent) => {
|
|
53
84
|
toolboxHeight.value = nativeEvent.layout.height;
|
|
85
|
+
setChromeHeight(nativeEvent.layout.height);
|
|
54
86
|
},
|
|
55
87
|
[toolboxHeight]
|
|
56
88
|
);
|
|
@@ -62,6 +94,21 @@ export const MessageToolboxWrapper = memo(
|
|
|
62
94
|
[reactionHeight]
|
|
63
95
|
);
|
|
64
96
|
|
|
97
|
+
// Same quantities `translateY` below subtracts, so the clone is never
|
|
98
|
+
// allowed to be taller than the space that shift can actually recover —
|
|
99
|
+
// `translateY` only moves up, so an over-tall clone pushes the toolbox off
|
|
100
|
+
// the bottom of the screen with no way back.
|
|
101
|
+
const maxCloneHeight = Math.max(
|
|
102
|
+
windowHeight -
|
|
103
|
+
insets.top -
|
|
104
|
+
topOffset -
|
|
105
|
+
(insets.bottom || 32) -
|
|
106
|
+
chromeHeight -
|
|
107
|
+
3 * MENU_GAP,
|
|
108
|
+
MIN_CLONE_HEIGHT
|
|
109
|
+
);
|
|
110
|
+
const preview = useClonePreview(maxCloneHeight);
|
|
111
|
+
|
|
65
112
|
const translateY = useDerivedValue(() => {
|
|
66
113
|
const menuGap =
|
|
67
114
|
(reactionHeight.value > 0 ? MENU_GAP : 0) +
|
|
@@ -85,6 +132,10 @@ export const MessageToolboxWrapper = memo(
|
|
|
85
132
|
width: rect.width,
|
|
86
133
|
gap: MENU_GAP,
|
|
87
134
|
transform: [{ translateY: translateY.value }],
|
|
135
|
+
// Until the toolbox has measured, the clone's budget is over-large and
|
|
136
|
+
// `translateY` is computed against a toolbox height of 0, which produces
|
|
137
|
+
// a visible two-stage slide on every open. Lay out, then reveal.
|
|
138
|
+
opacity: chromeHeight > 0 ? 1 : 0,
|
|
88
139
|
}));
|
|
89
140
|
|
|
90
141
|
return (
|
|
@@ -97,13 +148,40 @@ export const MessageToolboxWrapper = memo(
|
|
|
97
148
|
containerStyles,
|
|
98
149
|
]}
|
|
99
150
|
>
|
|
100
|
-
<
|
|
101
|
-
|
|
151
|
+
<ScrollView
|
|
152
|
+
style={[styles.clone, { maxHeight: maxCloneHeight }]}
|
|
102
153
|
collapsable={false}
|
|
103
154
|
onLayout={onMessageCloneLayout}
|
|
155
|
+
onContentSizeChange={preview.onContentSizeChange}
|
|
156
|
+
bounces={false}
|
|
157
|
+
showsVerticalScrollIndicator={false}
|
|
104
158
|
>
|
|
105
|
-
{
|
|
106
|
-
|
|
159
|
+
<Pressable onPress={onDismiss} disabled={preview.dismissDisabled}>
|
|
160
|
+
<View
|
|
161
|
+
pointerEvents={isCloneInteractive ? 'box-none' : 'none'}
|
|
162
|
+
style={isOutgoing ? styles.rowEnd : styles.rowStart}
|
|
163
|
+
>
|
|
164
|
+
{/* While the Pressable above stands down (see useClonePreview),
|
|
165
|
+
the empty space beside the bubble still has to dismiss. This
|
|
166
|
+
layer sits BEHIND the message as a sibling, so it is not an
|
|
167
|
+
ancestor of the text's ScrollView and cannot block it. Being
|
|
168
|
+
absolute, it also leaves `message` the row's only flex child
|
|
169
|
+
(the 80% width contract documented below). */}
|
|
170
|
+
{preview.dismissDisabled && (
|
|
171
|
+
<Pressable
|
|
172
|
+
style={StyleSheet.absoluteFill}
|
|
173
|
+
onPress={onDismiss}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
{/* Expanded text scrolls inside its own bubble within this
|
|
177
|
+
budget, so this outer scroll only remains for content that
|
|
178
|
+
cannot (a tall image), and the bubble keeps its corners. */}
|
|
179
|
+
<ClonePreviewBudgetContext.Provider value={preview.budget}>
|
|
180
|
+
{message}
|
|
181
|
+
</ClonePreviewBudgetContext.Provider>
|
|
182
|
+
</View>
|
|
183
|
+
</Pressable>
|
|
184
|
+
</ScrollView>
|
|
107
185
|
<View collapsable={false} onLayout={onReactionLayout}>
|
|
108
186
|
{reaction}
|
|
109
187
|
</View>
|
|
@@ -116,3 +194,31 @@ export const MessageToolboxWrapper = memo(
|
|
|
116
194
|
);
|
|
117
195
|
|
|
118
196
|
MessageToolboxWrapper.displayName = 'MessageToolboxWrapper';
|
|
197
|
+
|
|
198
|
+
const styles = StyleSheet.create({
|
|
199
|
+
// maxHeight belongs on the ScrollView itself — inside an auto-height parent a
|
|
200
|
+
// ScrollView collapses to zero.
|
|
201
|
+
//
|
|
202
|
+
// `rowStart`/`rowEnd` reproduce the contract `LegendChatMessage` gets from
|
|
203
|
+
// the real list row: its root (`maxWidth: '80%'`) must be the DIRECT child
|
|
204
|
+
// of a definite-width, non-stretching (`alignItems`) View. Any auto-width
|
|
205
|
+
// node in between (an extra View, a non-stretched Pressable) leaves the 80%
|
|
206
|
+
// with no definite basis, and Yoga collapses the bubble toward min-content —
|
|
207
|
+
// on device, "M2" wrapping to "M" / "2". The Pressable sits outside this
|
|
208
|
+
// View, stretched by the ScrollView's content container, so the chain from
|
|
209
|
+
// the clone's measured width down to the bubble stays definite throughout.
|
|
210
|
+
// Not `messageRowLeft/Right` directly: the outer Animated.View already
|
|
211
|
+
// applies their padding, which would double up here.
|
|
212
|
+
clone: {
|
|
213
|
+
flexGrow: 0,
|
|
214
|
+
width: '100%',
|
|
215
|
+
},
|
|
216
|
+
rowStart: {
|
|
217
|
+
width: '100%',
|
|
218
|
+
alignItems: 'flex-start',
|
|
219
|
+
},
|
|
220
|
+
rowEnd: {
|
|
221
|
+
width: '100%',
|
|
222
|
+
alignItems: 'flex-end',
|
|
223
|
+
},
|
|
224
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import type { ClonePreviewBudget } from '../../../components/messages/PreviewScrollableText';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wiring between the overlay's clone and the expanded text that scrolls
|
|
6
|
+
* inside it (see `PreviewScrollableText`).
|
|
7
|
+
*
|
|
8
|
+
* `dismissDisabled`: the clone sits in a tap-to-dismiss Pressable. On iOS a
|
|
9
|
+
* ScrollView refuses to scroll while an ANCESTOR holds the JS responder
|
|
10
|
+
* (`RCTScrollView _shouldDisableScrollInteraction`), so with the Pressable
|
|
11
|
+
* enabled a drag on the text never scrolls — it ends as a tap and closes the
|
|
12
|
+
* overlay. While the text is scrollable the Pressable stands down; the
|
|
13
|
+
* backdrop and the menu still dismiss.
|
|
14
|
+
*/
|
|
15
|
+
export const useClonePreview = (maxHeight: number) => {
|
|
16
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
17
|
+
const [textScrollable, setTextScrollable] = useState(false);
|
|
18
|
+
|
|
19
|
+
const onContentSizeChange = useCallback(
|
|
20
|
+
(_width: number, height: number) => setContentHeight(height),
|
|
21
|
+
[]
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const budget = useMemo<ClonePreviewBudget>(
|
|
25
|
+
() => ({
|
|
26
|
+
maxHeight,
|
|
27
|
+
contentHeight,
|
|
28
|
+
onScrollableChange: setTextScrollable,
|
|
29
|
+
}),
|
|
30
|
+
[maxHeight, contentHeight]
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return { budget, onContentSizeChange, dismissDisabled: textScrollable };
|
|
34
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import {
|
|
3
|
+
expandedKey,
|
|
4
|
+
withoutSurface,
|
|
5
|
+
type MessageSurface,
|
|
6
|
+
} from '../utils/expandableText';
|
|
7
|
+
|
|
8
|
+
export type { MessageSurface };
|
|
9
|
+
export { expandedKey };
|
|
10
|
+
|
|
11
|
+
export interface ExpandedMessageStore {
|
|
12
|
+
/**
|
|
13
|
+
* Flat record rather than a Set so a row can subscribe with a cheap boolean
|
|
14
|
+
* selector — `useExpandedMessageStore(s => !!s.expanded[key])` — which stays
|
|
15
|
+
* referentially stable for every message the change didn't touch.
|
|
16
|
+
*/
|
|
17
|
+
expanded: Record<string, true>;
|
|
18
|
+
expand: (surface: MessageSurface, messageId: string) => void;
|
|
19
|
+
clearSurface: (surface: MessageSurface) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const useExpandedMessageStore = create<ExpandedMessageStore>()(
|
|
23
|
+
(set, get) => ({
|
|
24
|
+
expanded: {},
|
|
25
|
+
|
|
26
|
+
expand: (surface, messageId) => {
|
|
27
|
+
// A message with no id would alias onto `${surface}:` and expand every
|
|
28
|
+
// other id-less message with it.
|
|
29
|
+
if (!messageId) return;
|
|
30
|
+
|
|
31
|
+
const key = expandedKey(surface, messageId);
|
|
32
|
+
if (get().expanded[key]) return;
|
|
33
|
+
|
|
34
|
+
set({ expanded: { ...get().expanded, [key]: true } });
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
clearSurface: (surface) => {
|
|
38
|
+
const next = withoutSurface(get().expanded, surface);
|
|
39
|
+
if (next !== get().expanded) set({ expanded: next });
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
);
|
package/src/store/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import type { ConversationInputStatus } from '@droppii/openim-rn-client-sdk';
|
|
3
|
+
import type {
|
|
4
|
+
ConversationInputState,
|
|
5
|
+
InputStateMap,
|
|
6
|
+
} from '../utils/inputState';
|
|
7
|
+
|
|
8
|
+
export interface InputStateStore {
|
|
9
|
+
/** conversationID -> userID -> record. */
|
|
10
|
+
byConversation: InputStateMap;
|
|
11
|
+
/**
|
|
12
|
+
* Apply one OnInputStatusChanged payload.
|
|
13
|
+
*
|
|
14
|
+
* A non-empty `platformIDs` means that user is composing; an empty list is
|
|
15
|
+
* how the SDK reports that they stopped, so it removes them outright rather
|
|
16
|
+
* than storing an inactive record.
|
|
17
|
+
*/
|
|
18
|
+
applyInputStatus: (status: ConversationInputStatus) => void;
|
|
19
|
+
/** Remove one user now — their message arrived, or they went idle. */
|
|
20
|
+
removeTyper: (conversationID: string, userID: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const withoutUser = (
|
|
24
|
+
state: ConversationInputState,
|
|
25
|
+
userID: string
|
|
26
|
+
): ConversationInputState | undefined => {
|
|
27
|
+
const next = { ...state };
|
|
28
|
+
delete next[userID];
|
|
29
|
+
// Undefined rather than an empty object, so a leftover record can never read
|
|
30
|
+
// as "somebody is typing".
|
|
31
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const useInputStateStore = create<InputStateStore>()((set) => ({
|
|
35
|
+
byConversation: {},
|
|
36
|
+
|
|
37
|
+
applyInputStatus({ conversationID, userID, platformIDs }) {
|
|
38
|
+
if (!conversationID || !userID) return;
|
|
39
|
+
|
|
40
|
+
set((state) => {
|
|
41
|
+
const current = state.byConversation[conversationID];
|
|
42
|
+
|
|
43
|
+
if (!platformIDs || platformIDs.length === 0) {
|
|
44
|
+
if (!current?.[userID]) return state;
|
|
45
|
+
const remaining = withoutUser(current, userID);
|
|
46
|
+
const byConversation = { ...state.byConversation };
|
|
47
|
+
if (remaining) byConversation[conversationID] = remaining;
|
|
48
|
+
else delete byConversation[conversationID];
|
|
49
|
+
return { byConversation };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
byConversation: {
|
|
54
|
+
...state.byConversation,
|
|
55
|
+
[conversationID]: {
|
|
56
|
+
...current,
|
|
57
|
+
[userID]: { userID, platformIDs, updatedAt: Date.now() },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
removeTyper(conversationID, userID) {
|
|
65
|
+
set((state) => {
|
|
66
|
+
const current = state.byConversation[conversationID];
|
|
67
|
+
if (!current?.[userID]) return state;
|
|
68
|
+
|
|
69
|
+
const remaining = withoutUser(current, userID);
|
|
70
|
+
const byConversation = { ...state.byConversation };
|
|
71
|
+
if (remaining) byConversation[conversationID] = remaining;
|
|
72
|
+
else delete byConversation[conversationID];
|
|
73
|
+
return { byConversation };
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const i18nKey: Record<string, string> = {
|
|
2
2
|
'thread_card_bot_crm_name': 'Droppii Hỗ Trợ',
|
|
3
3
|
'thread_card_fallback_name': 'Người dùng',
|
|
4
|
+
'chat:typing_single': '<bold>{{name}}</bold> đang nhập',
|
|
5
|
+
'chat:typing_multiple': 'Nhiều người đang nhập',
|
|
4
6
|
'chat:file_too_large':
|
|
5
7
|
'Tệp tải lên không hợp lệ. Kích thước tệp không được vượt quá {{maxSize}}MB',
|
|
6
8
|
'chat:file_max_count': 'Chỉ có thể đính kèm tối đa 3 tệp',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type MessageSurface = 'timeline' | 'pin';
|
|
2
|
+
|
|
3
|
+
export const COLLAPSED_LINES = 5;
|
|
4
|
+
|
|
5
|
+
const MEASURE_THRESHOLD_CHARS = 100;
|
|
6
|
+
|
|
7
|
+
export const expandedKey = (surface: MessageSurface, messageId: string) =>
|
|
8
|
+
`${surface}:${messageId}`;
|
|
9
|
+
|
|
10
|
+
const countNewlines = (text: string) => {
|
|
11
|
+
let count = 0;
|
|
12
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
13
|
+
if (text.charCodeAt(i) === 10) count += 1;
|
|
14
|
+
}
|
|
15
|
+
return count;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Character count alone is not an approximation of rendered lines — it is
|
|
20
|
+
* wrong for the shapes this chat carries most (pasted addresses, order lists),
|
|
21
|
+
* where an eight-line message can be under 70 characters. Newlines cost one
|
|
22
|
+
* character and a whole line, so both signals are needed. Message text is
|
|
23
|
+
* sender-controlled, so a length-only gate is also trivially defeated.
|
|
24
|
+
*/
|
|
25
|
+
export const isMeasurementCandidate = (text: string) =>
|
|
26
|
+
text.length >= MEASURE_THRESHOLD_CHARS ||
|
|
27
|
+
countNewlines(text) >= COLLAPSED_LINES;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns a copy without the entries belonging to `surface`, or the original
|
|
31
|
+
* reference when nothing matched — callers use that identity to skip a
|
|
32
|
+
* pointless store update.
|
|
33
|
+
*/
|
|
34
|
+
export const withoutSurface = (
|
|
35
|
+
expanded: Record<string, true>,
|
|
36
|
+
surface: MessageSurface
|
|
37
|
+
): Record<string, true> => {
|
|
38
|
+
const prefix = `${surface}:`;
|
|
39
|
+
const next: Record<string, true> = {};
|
|
40
|
+
let removed = false;
|
|
41
|
+
|
|
42
|
+
for (const key in expanded) {
|
|
43
|
+
if (key.startsWith(prefix)) {
|
|
44
|
+
removed = true;
|
|
45
|
+
} else {
|
|
46
|
+
next[key] = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return removed ? next : expanded;
|
|
51
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cut-point arithmetic for the inline `... Xem thêm` preview. Everything here
|
|
3
|
+
* works on the lines RN reports from `onTextLayout`, whose `text` includes the
|
|
4
|
+
* line's trailing break — so joining them reproduces the rendered string and a
|
|
5
|
+
* running length is a valid character offset into it.
|
|
6
|
+
*/
|
|
7
|
+
export interface RenderedLine {
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// How far back a shrink may look for a word boundary before accepting a
|
|
12
|
+
// mid-word cut. One long unbroken token (a URL) must still be cuttable.
|
|
13
|
+
const WORD_SNAP_WINDOW = 20;
|
|
14
|
+
|
|
15
|
+
const isSpace = (char: string | undefined) => !!char && /\s/.test(char);
|
|
16
|
+
|
|
17
|
+
const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
|
18
|
+
|
|
19
|
+
export const joinLines = (lines: readonly RenderedLine[]) =>
|
|
20
|
+
lines.map((line) => line.text).join('');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Backs `cut` off trailing whitespace — a kept `\n` would push the suffix onto
|
|
24
|
+
* a line of its own — and off half an emoji, which would render as a tofu box.
|
|
25
|
+
*/
|
|
26
|
+
export const settleCut = (full: string, cut: number) => {
|
|
27
|
+
let next = Math.max(0, Math.min(cut, full.length));
|
|
28
|
+
if (next > 0 && isHighSurrogate(full.charCodeAt(next - 1))) next -= 1;
|
|
29
|
+
while (next > 0 && isSpace(full[next - 1])) next -= 1;
|
|
30
|
+
return next;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** First guess: everything on the first `maxLines` lines. */
|
|
34
|
+
export const initialCut = (
|
|
35
|
+
lines: readonly RenderedLine[],
|
|
36
|
+
maxLines: number
|
|
37
|
+
) => {
|
|
38
|
+
const full = joinLines(lines);
|
|
39
|
+
return settleCut(full, joinLines(lines.slice(0, maxLines)).length);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The suffix did not fit and wrapped past `maxLines`. Drop at least as many
|
|
44
|
+
* characters as spilled over — the spill includes the suffix itself, so this
|
|
45
|
+
* converges in one or two passes — then prefer ending on a word.
|
|
46
|
+
*/
|
|
47
|
+
export const shrinkCut = (
|
|
48
|
+
full: string,
|
|
49
|
+
cut: number,
|
|
50
|
+
fittedLines: readonly RenderedLine[],
|
|
51
|
+
maxLines: number
|
|
52
|
+
) => {
|
|
53
|
+
const overflow = joinLines(fittedLines.slice(maxLines)).length;
|
|
54
|
+
let next = Math.max(0, cut - Math.max(overflow, 1));
|
|
55
|
+
|
|
56
|
+
const floor = Math.max(0, next - WORD_SNAP_WINDOW);
|
|
57
|
+
for (let i = next; i > floor; i -= 1) {
|
|
58
|
+
if (isSpace(full[i - 1])) {
|
|
59
|
+
next = i;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return settleCut(full, next);
|
|
65
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Platform } from '@droppii/openim-rn-client-sdk';
|
|
2
|
+
|
|
3
|
+
/** A typer drops out this long after their last signal — BR-002. */
|
|
4
|
+
export const INPUT_STATE_TTL = 3_000;
|
|
5
|
+
|
|
6
|
+
/** A single name longer than this is truncated before it goes in the sentence. */
|
|
7
|
+
const TYPING_NAME_MAX_LENGTH = 30;
|
|
8
|
+
|
|
9
|
+
export interface InputStateRecord {
|
|
10
|
+
userID: string;
|
|
11
|
+
/** Devices that user is composing from. An empty list means they stopped. */
|
|
12
|
+
platformIDs: Platform[];
|
|
13
|
+
/** When their most recent signal arrived — the clock the TTL runs against. */
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** userID -> record, for one conversation. */
|
|
18
|
+
export type ConversationInputState = Readonly<Record<string, InputStateRecord>>;
|
|
19
|
+
|
|
20
|
+
/** conversationID -> that conversation's typers. */
|
|
21
|
+
export type InputStateMap = Readonly<Record<string, ConversationInputState>>;
|
|
22
|
+
|
|
23
|
+
const EMPTY_TYPERS: readonly InputStateRecord[] = [];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Typers still inside the TTL window, oldest signal first.
|
|
27
|
+
*
|
|
28
|
+
* Expiry is derived from `updatedAt` rather than driven by one timer per user:
|
|
29
|
+
* the whole point is that each typer runs on their own clock. With A typing at
|
|
30
|
+
* t=0 and B at t=2s, A has to drop at t=3s while B stays until t=5s — a single
|
|
31
|
+
* shared timer reset by the newest signal would keep both alive to t=5s.
|
|
32
|
+
*/
|
|
33
|
+
export const getActiveTypers = (
|
|
34
|
+
state: ConversationInputState | undefined,
|
|
35
|
+
now: number
|
|
36
|
+
): readonly InputStateRecord[] => {
|
|
37
|
+
if (!state) return EMPTY_TYPERS;
|
|
38
|
+
|
|
39
|
+
const active = Object.values(state).filter(
|
|
40
|
+
(record) =>
|
|
41
|
+
record.platformIDs.length > 0 && now - record.updatedAt < INPUT_STATE_TTL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (active.length === 0) return EMPTY_TYPERS;
|
|
45
|
+
return active.sort((a, b) => a.updatedAt - b.updatedAt);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* When the next active typer falls out of the window, or null if none will.
|
|
50
|
+
*
|
|
51
|
+
* The caller uses this to wake up exactly once per expiry instead of polling.
|
|
52
|
+
*/
|
|
53
|
+
export const getNextExpiryAt = (
|
|
54
|
+
state: ConversationInputState | undefined,
|
|
55
|
+
now: number
|
|
56
|
+
): number | null => {
|
|
57
|
+
const active = getActiveTypers(state, now);
|
|
58
|
+
if (active.length === 0) return null;
|
|
59
|
+
|
|
60
|
+
// Sorted oldest first, so the first entry expires first.
|
|
61
|
+
return active[0]!.updatedAt + INPUT_STATE_TTL;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const truncateTypingName = (name: string): string =>
|
|
65
|
+
name.length > TYPING_NAME_MAX_LENGTH
|
|
66
|
+
? `${name.slice(0, TYPING_NAME_MAX_LENGTH)}...`
|
|
67
|
+
: name;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Which copy to render, as a key plus its values.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately not a finished string: the name is styled differently from the
|
|
73
|
+
* rest of the sentence, so the caller needs the pieces separately to hand them
|
|
74
|
+
* to a rich-text renderer. Keeping translation out of here also leaves this
|
|
75
|
+
* module free of any dependency on the i18n layer.
|
|
76
|
+
*/
|
|
77
|
+
export interface TypingTextDescriptor {
|
|
78
|
+
key: string;
|
|
79
|
+
values?: Record<string, string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const TYPING_TEXT_KEYS = {
|
|
83
|
+
single: 'chat:typing_single',
|
|
84
|
+
multiple: 'chat:typing_multiple',
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Copy for a set of typers, or null when the indicator should be hidden.
|
|
89
|
+
*
|
|
90
|
+
* Only one typer is ever named. Beyond that the sentence collapses, so the
|
|
91
|
+
* caller never has to resolve a second name.
|
|
92
|
+
*/
|
|
93
|
+
export const resolveTypingText = (
|
|
94
|
+
typerCount: number,
|
|
95
|
+
singleName?: string
|
|
96
|
+
): TypingTextDescriptor | null => {
|
|
97
|
+
if (typerCount === 0) return null;
|
|
98
|
+
if (typerCount > 1) return { key: TYPING_TEXT_KEYS.multiple };
|
|
99
|
+
if (!singleName) return null;
|
|
100
|
+
return {
|
|
101
|
+
key: TYPING_TEXT_KEYS.single,
|
|
102
|
+
values: { name: truncateTypingName(singleName) },
|
|
103
|
+
};
|
|
104
|
+
};
|
package/src/utils/message.ts
CHANGED
|
@@ -119,6 +119,21 @@ export const hasNewHistoryMessages = (
|
|
|
119
119
|
});
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Conversation ids a message could be filed under.
|
|
124
|
+
*
|
|
125
|
+
* A 1:1 id is built from both participants and their order is not recoverable
|
|
126
|
+
* from the message alone, so both spellings come back — `belongsToConversation`
|
|
127
|
+
* compares against both for exactly the same reason.
|
|
128
|
+
*/
|
|
129
|
+
export const conversationIdsOfMessage = (message: MessageItem): string[] =>
|
|
130
|
+
message.sessionType === SessionType.Group
|
|
131
|
+
? [getConversationID(SessionType.Group, message.groupID)]
|
|
132
|
+
: [
|
|
133
|
+
getConversationID(SessionType.Single, message.sendID, message.recvID),
|
|
134
|
+
getConversationID(SessionType.Single, message.recvID, message.sendID),
|
|
135
|
+
];
|
|
136
|
+
|
|
122
137
|
export const belongsToConversation = (
|
|
123
138
|
message: MessageItem,
|
|
124
139
|
conversationId?: string,
|