@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.
Files changed (152) hide show
  1. package/lib/module/components/TypingDots/TypingDots.js +131 -0
  2. package/lib/module/components/TypingDots/TypingDots.js.map +1 -0
  3. package/lib/module/components/TypingDots/index.js +4 -0
  4. package/lib/module/components/TypingDots/index.js.map +1 -0
  5. package/lib/module/components/messages/ExpandableMessageText.js +112 -0
  6. package/lib/module/components/messages/ExpandableMessageText.js.map +1 -0
  7. package/lib/module/components/messages/PreviewScrollableText.js +80 -0
  8. package/lib/module/components/messages/PreviewScrollableText.js.map +1 -0
  9. package/lib/module/components/messages/linkMessage/index.js +21 -12
  10. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  11. package/lib/module/components/messages/quotedMessage/index.js +12 -2
  12. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  13. package/lib/module/components/messages/textMessage/index.js +7 -1
  14. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  15. package/lib/module/components/messages/types.js.map +1 -1
  16. package/lib/module/core/useChatListener.js +26 -2
  17. package/lib/module/core/useChatListener.js.map +1 -1
  18. package/lib/module/hooks/groups/useGroupMemberName.js +32 -0
  19. package/lib/module/hooks/groups/useGroupMemberName.js.map +1 -0
  20. package/lib/module/hooks/query-keys.js +2 -1
  21. package/lib/module/hooks/query-keys.js.map +1 -1
  22. package/lib/module/hooks/useExpandableText.js +120 -0
  23. package/lib/module/hooks/useExpandableText.js.map +1 -0
  24. package/lib/module/hooks/useInputState.js +38 -0
  25. package/lib/module/hooks/useInputState.js.map +1 -0
  26. package/lib/module/hooks/useInputStateBroadcast.js +53 -0
  27. package/lib/module/hooks/useInputStateBroadcast.js.map +1 -0
  28. package/lib/module/screens/chat-detail/ChatComposer.js +15 -3
  29. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  30. package/lib/module/screens/chat-detail/ChatDetail.js +11 -0
  31. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  32. package/lib/module/screens/chat-detail/ChatListLegend.js +2 -1
  33. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  34. package/lib/module/screens/chat-detail/ChatTypingIndicator.js +86 -0
  35. package/lib/module/screens/chat-detail/ChatTypingIndicator.js.map +1 -0
  36. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +7 -0
  37. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  38. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +10 -3
  39. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  40. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +11 -2
  41. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  42. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +79 -6
  43. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -1
  44. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js +30 -0
  45. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js.map +1 -0
  46. package/lib/module/store/expandedMessage.js +28 -0
  47. package/lib/module/store/expandedMessage.js.map +1 -0
  48. package/lib/module/store/index.js +1 -0
  49. package/lib/module/store/index.js.map +1 -1
  50. package/lib/module/store/inputState.js +64 -0
  51. package/lib/module/store/inputState.js.map +1 -0
  52. package/lib/module/translation/resources/i18n.js +2 -0
  53. package/lib/module/translation/resources/i18n.js.map +1 -1
  54. package/lib/module/utils/expandableText.js +41 -0
  55. package/lib/module/utils/expandableText.js.map +1 -0
  56. package/lib/module/utils/inlineTruncation.js +51 -0
  57. package/lib/module/utils/inlineTruncation.js.map +1 -0
  58. package/lib/module/utils/inputState.js +77 -0
  59. package/lib/module/utils/inputState.js.map +1 -0
  60. package/lib/module/utils/message.js +9 -0
  61. package/lib/module/utils/message.js.map +1 -1
  62. package/lib/module/utils/truncateTextChildren.js +38 -0
  63. package/lib/module/utils/truncateTextChildren.js.map +1 -0
  64. package/lib/typescript/src/components/TypingDots/TypingDots.d.ts +14 -0
  65. package/lib/typescript/src/components/TypingDots/TypingDots.d.ts.map +1 -0
  66. package/lib/typescript/src/components/TypingDots/index.d.ts +3 -0
  67. package/lib/typescript/src/components/TypingDots/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts +25 -0
  69. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts.map +1 -0
  70. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts +29 -0
  71. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts.map +1 -0
  72. package/lib/typescript/src/components/messages/linkMessage/index.d.ts +1 -1
  73. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  74. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/messages/textMessage/index.d.ts +1 -1
  76. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/messages/types.d.ts +18 -0
  78. package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
  79. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  80. package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts +10 -0
  81. package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts.map +1 -0
  82. package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
  83. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  84. package/lib/typescript/src/hooks/useExpandableText.d.ts +13 -0
  85. package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -0
  86. package/lib/typescript/src/hooks/useInputState.d.ts +21 -0
  87. package/lib/typescript/src/hooks/useInputState.d.ts.map +1 -0
  88. package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts +14 -0
  89. package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts.map +1 -0
  90. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  91. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  92. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  93. package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts +8 -0
  94. package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts.map +1 -0
  95. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  96. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +11 -1
  97. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  98. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  99. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +14 -1
  100. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -1
  101. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts +18 -0
  102. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts.map +1 -0
  103. package/lib/typescript/src/store/expandedMessage.d.ts +15 -0
  104. package/lib/typescript/src/store/expandedMessage.d.ts.map +1 -0
  105. package/lib/typescript/src/store/index.d.ts +1 -0
  106. package/lib/typescript/src/store/index.d.ts.map +1 -1
  107. package/lib/typescript/src/store/inputState.d.ts +18 -0
  108. package/lib/typescript/src/store/inputState.d.ts.map +1 -0
  109. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  110. package/lib/typescript/src/utils/expandableText.d.ts +18 -0
  111. package/lib/typescript/src/utils/expandableText.d.ts.map +1 -0
  112. package/lib/typescript/src/utils/inlineTruncation.d.ts +24 -0
  113. package/lib/typescript/src/utils/inlineTruncation.d.ts.map +1 -0
  114. package/lib/typescript/src/utils/inputState.d.ts +54 -0
  115. package/lib/typescript/src/utils/inputState.d.ts.map +1 -0
  116. package/lib/typescript/src/utils/message.d.ts +8 -0
  117. package/lib/typescript/src/utils/message.d.ts.map +1 -1
  118. package/lib/typescript/src/utils/truncateTextChildren.d.ts +3 -0
  119. package/lib/typescript/src/utils/truncateTextChildren.d.ts.map +1 -0
  120. package/package.json +1 -1
  121. package/src/components/TypingDots/TypingDots.tsx +172 -0
  122. package/src/components/TypingDots/index.ts +2 -0
  123. package/src/components/messages/ExpandableMessageText.tsx +145 -0
  124. package/src/components/messages/PreviewScrollableText.tsx +98 -0
  125. package/src/components/messages/linkMessage/index.tsx +30 -21
  126. package/src/components/messages/quotedMessage/index.tsx +14 -2
  127. package/src/components/messages/textMessage/index.tsx +11 -2
  128. package/src/components/messages/types.ts +18 -0
  129. package/src/core/useChatListener.ts +35 -1
  130. package/src/hooks/groups/useGroupMemberName.ts +31 -0
  131. package/src/hooks/query-keys.ts +2 -0
  132. package/src/hooks/useExpandableText.ts +139 -0
  133. package/src/hooks/useInputState.ts +57 -0
  134. package/src/hooks/useInputStateBroadcast.ts +53 -0
  135. package/src/screens/chat-detail/ChatComposer.tsx +17 -2
  136. package/src/screens/chat-detail/ChatDetail.tsx +11 -0
  137. package/src/screens/chat-detail/ChatListLegend.tsx +2 -0
  138. package/src/screens/chat-detail/ChatTypingIndicator.tsx +89 -0
  139. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +8 -0
  140. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +22 -2
  141. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -1
  142. package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +111 -5
  143. package/src/screens/chat-detail/messageToolbox/useClonePreview.ts +34 -0
  144. package/src/store/expandedMessage.ts +42 -0
  145. package/src/store/index.ts +1 -0
  146. package/src/store/inputState.ts +76 -0
  147. package/src/translation/resources/i18n.ts +2 -0
  148. package/src/utils/expandableText.ts +51 -0
  149. package/src/utils/inlineTruncation.ts +65 -0
  150. package/src/utils/inputState.ts +104 -0
  151. package/src/utils/message.ts +15 -0
  152. 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={message.clientMsgID || message.serverMsgID || ''}
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
- <View
101
- pointerEvents="none"
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
- {message}
106
- </View>
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
+ );
@@ -1,3 +1,4 @@
1
1
  export * from './storeConfig';
2
2
  export * from './user';
3
3
  export * from './conversation';
4
+ export * from './inputState';
@@ -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
+ };
@@ -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,