@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
@@ -27,6 +27,14 @@ export declare const resolveHistoryLastMinSeq: (history: {
27
27
  }, fallbackMessage?: MessageItem) => number;
28
28
  export declare const getHistoryPaginationAnchor: (messages: MessageItem[]) => HistoryPaginationAnchor | null;
29
29
  export declare const hasNewHistoryMessages: (current: MessageItem[], incoming: MessageItem[]) => boolean;
30
+ /**
31
+ * Conversation ids a message could be filed under.
32
+ *
33
+ * A 1:1 id is built from both participants and their order is not recoverable
34
+ * from the message alone, so both spellings come back — `belongsToConversation`
35
+ * compares against both for exactly the same reason.
36
+ */
37
+ export declare const conversationIdsOfMessage: (message: MessageItem) => string[];
30
38
  export declare const belongsToConversation: (message: MessageItem, conversationId?: string, targetUserId?: string) => boolean;
31
39
  export declare const getReceiverId: (conversation?: ConversationItem) => {
32
40
  groupID: string;
@@ -1 +1 @@
1
- {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../../../src/utils/message.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,WAAW,EACjB,MAAM,+BAA+B,CAAC;AAMvC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,SAAS,WAAW,WACN,CAAC;AAK9C;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,SAAS,WAAW,EAAE,OAAO,MAAM,WACA,CAAC;AAEvE,eAAO,MAAM,aAAa,GACxB,SAAS,WAAW,EAAE,EACtB,UAAU,WAAW,EAAE,KACtB,WAAW,EAab,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAClC,SAAS,WAAW,GAAG,MAAM,KAC5B,WAYF,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,wBAAwB,GACnC,SAAS;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,EAChC,kBAAkB,WAAW,KAC5B,MAMF,CAAC;AAEF,eAAO,MAAM,0BAA0B,GACrC,UAAU,WAAW,EAAE,KACtB,uBAAuB,GAAG,IAkB5B,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,SAAS,WAAW,EAAE,EACtB,UAAU,WAAW,EAAE,KACtB,OASF,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,SAAS,WAAW,EACpB,iBAAiB,MAAM,EACvB,eAAe,MAAM,KACpB,OA2BF,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,eAAe,gBAAgB;;;CAW5D,CAAC"}
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../../../src/utils/message.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,WAAW,EACjB,MAAM,+BAA+B,CAAC;AAMvC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,SAAS,WAAW,WACN,CAAC;AAK9C;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,SAAS,WAAW,EAAE,OAAO,MAAM,WACA,CAAC;AAEvE,eAAO,MAAM,aAAa,GACxB,SAAS,WAAW,EAAE,EACtB,UAAU,WAAW,EAAE,KACtB,WAAW,EAab,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAClC,SAAS,WAAW,GAAG,MAAM,KAC5B,WAYF,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,wBAAwB,GACnC,SAAS;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,EAChC,kBAAkB,WAAW,KAC5B,MAMF,CAAC;AAEF,eAAO,MAAM,0BAA0B,GACrC,UAAU,WAAW,EAAE,KACtB,uBAAuB,GAAG,IAkB5B,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,SAAS,WAAW,EAAE,EACtB,UAAU,WAAW,EAAE,KACtB,OASF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,WAAW,KAAG,MAAM,EAM/D,CAAC;AAER,eAAO,MAAM,qBAAqB,GAChC,SAAS,WAAW,EACpB,iBAAiB,MAAM,EACvB,eAAe,MAAM,KACpB,OA2BF,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,eAAe,gBAAgB;;;CAW5D,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare const truncateTextChildren: (node: ReactNode, limit: number) => ReactNode;
3
+ //# sourceMappingURL=truncateTextChildren.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"truncateTextChildren.d.ts","sourceRoot":"","sources":["../../../../src/utils/truncateTextChildren.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AA4CrE,eAAO,MAAM,oBAAoB,GAAI,MAAM,SAAS,EAAE,OAAO,MAAM,cAC7C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droppii-org/chat-mobile",
3
- "version": "0.2.105",
3
+ "version": "0.2.107",
4
4
  "description": "Droppii chat mobile",
5
5
  "keywords": [
6
6
  "react-native",
@@ -0,0 +1,172 @@
1
+ import { memo, useEffect } from 'react';
2
+ import Animated, {
3
+ Easing,
4
+ useAnimatedProps,
5
+ useReducedMotion,
6
+ useSharedValue,
7
+ withDelay,
8
+ withRepeat,
9
+ withSequence,
10
+ withSpring,
11
+ withTiming,
12
+ } from 'react-native-reanimated';
13
+ import Svg, { G, Rect, type SvgProps } from 'react-native-svg';
14
+
15
+ /**
16
+ * The three bouncing dots from `assets/images/typing_indicator.svg`.
17
+ *
18
+ * SVGR cannot carry this one over: the motion lives in a `<style>` block of
19
+ * `@keyframes`, which it drops ("elements not supported by react-native-svg"),
20
+ * leaving `style={{ animation: ... }}` props pointing at rules that no longer
21
+ * exist. react-native-svg has no CSS animation engine either, so the keyframes
22
+ * are re-expressed here as reanimated timelines instead.
23
+ */
24
+
25
+ const VIEW_WIDTH = 48;
26
+ const VIEW_HEIGHT = 20;
27
+ const DOT_SIZE = 8;
28
+ /** x of each dot in the source — `translate(6 6)`, `(20 6)`, `(34 6)`. */
29
+ const DOT_X = [6, 20, 34];
30
+ const DOT_Y = 6;
31
+ /** The `translateY(-6px)` keyframe. */
32
+ const LIFT = 6;
33
+
34
+ const CYCLE_MS = 3150;
35
+ /** 7.14% of the cycle — the gap between one dot bouncing and the next. */
36
+ const BOUNCE_MS = CYCLE_MS / 14;
37
+ /** 14.29% of the cycle — one step of the opacity wave. */
38
+ const FADE_STEP_MS = CYCLE_MS / 7;
39
+ const FADE_STEPS = 7;
40
+
41
+ const HOLD_SHORT_MS = 900; // 14.29% → 42.86%
42
+ const HOLD_LONG_MS = 1350; // 57.14% → 100%
43
+
44
+ /** The three alpha levels the wave cycles through. */
45
+ const ALPHAS = [0.2, 0.35, 0.5];
46
+
47
+ /** The source's own colour, used when the caller does not pass one. */
48
+ const DEFAULT_FILL = '#1A1D22';
49
+
50
+ const RISE = {
51
+ duration: BOUNCE_MS,
52
+ easing: Easing.out(Easing.ease),
53
+ } as const;
54
+
55
+ /**
56
+ * The fall, not the rise, carries the character here: the source's `linear()`
57
+ * curve peaks at 1.364, so a dot dips 36.4% past its resting line before
58
+ * settling. A spring's first overshoot is exp(-piZ / sqrt(1 - Z^2)), which
59
+ * lands on 36.4% at a damping ratio of ~0.306 — 0.5 would reach only 16%,
60
+ * visibly flatter than drawn.
61
+ *
62
+ * `duration` matters as much as the ratio: a default spring settles whenever it
63
+ * settles, which would break the fixed-length cycle the two tracks share.
64
+ */
65
+ const FALL = { duration: BOUNCE_MS, dampingRatio: 0.306 } as const;
66
+
67
+ const AnimatedRect = Animated.createAnimatedComponent(Rect);
68
+
69
+ export interface TypingDotsProps extends Omit<SvgProps, 'fill'> {
70
+ /**
71
+ * Dot colour. Overrides the `fill` SvgProps would otherwise carry, because
72
+ * that one lands on the root <Svg> where the dots' own group already shadows
73
+ * it — passing it there does nothing.
74
+ *
75
+ * Only the colour: the source animates `fill: rgba(26, 29, 34, a)` but never
76
+ * changes the RGB, so the alpha half is driven separately as `fillOpacity`.
77
+ */
78
+ fill?: string;
79
+ }
80
+
81
+ const Dot = memo(({ index }: { index: number }) => {
82
+ const reducedMotion = useReducedMotion();
83
+ const lift = useSharedValue(0);
84
+ const alpha = useSharedValue(ALPHAS[index % ALPHAS.length]!);
85
+
86
+ useEffect(() => {
87
+ if (reducedMotion) return;
88
+
89
+ // Two bounces per cycle. A dot's turn comes one BOUNCE_MS after the one
90
+ // before it, and that delay applies only to the first pass — every
91
+ // iteration runs exactly CYCLE_MS, so the offset holds instead of drifting.
92
+ lift.value = withDelay(
93
+ index * BOUNCE_MS,
94
+ withRepeat(
95
+ withSequence(
96
+ withTiming(-LIFT, RISE),
97
+ withSpring(0, FALL),
98
+ withTiming(0, { duration: HOLD_SHORT_MS }),
99
+ withTiming(-LIFT, RISE),
100
+ withSpring(0, FALL),
101
+ withTiming(0, { duration: HOLD_LONG_MS })
102
+ ),
103
+ -1
104
+ )
105
+ );
106
+
107
+ // The opacity wave needs no delay: each dot enters the cycle at a different
108
+ // alpha instead, which is how the source staggers it.
109
+ alpha.value = withRepeat(
110
+ withSequence(
111
+ ...Array.from({ length: FADE_STEPS }, (_, step) =>
112
+ withTiming(ALPHAS[(index + step + 1) % ALPHAS.length]!, {
113
+ duration: FADE_STEP_MS,
114
+ easing: Easing.inOut(Easing.ease),
115
+ })
116
+ )
117
+ ),
118
+ -1
119
+ );
120
+ }, [index, lift, alpha, reducedMotion]);
121
+
122
+ // Driven as SVG attributes rather than a style transform: react-native-svg
123
+ // maps these straight onto the native node, keeping the work off the JS
124
+ // thread. `y` also replaces the source's stacked translateY pair.
125
+ const animatedProps = useAnimatedProps(() => ({
126
+ y: DOT_Y + lift.value,
127
+ fillOpacity: alpha.value,
128
+ }));
129
+
130
+ return (
131
+ <AnimatedRect
132
+ x={DOT_X[index]}
133
+ width={DOT_SIZE}
134
+ height={DOT_SIZE}
135
+ // The file says rx="50"; SVG clamps that to half the width. Stating the
136
+ // clamped value keeps the circle from depending on that behaviour.
137
+ rx={DOT_SIZE / 2}
138
+ animatedProps={animatedProps}
139
+ />
140
+ );
141
+ });
142
+
143
+ Dot.displayName = 'TypingDots.Dot';
144
+
145
+ export const TypingDots = memo(
146
+ ({
147
+ fill = DEFAULT_FILL,
148
+ width = VIEW_WIDTH,
149
+ height = VIEW_HEIGHT,
150
+ ...props
151
+ }: TypingDotsProps) => (
152
+ // No `overflow="visible"` despite the source setting it: a dot runs from
153
+ // y=0 at the top of its bounce to y=16.23 at the bottom of the overshoot,
154
+ // so nothing leaves the 48x20 box. Leaning on overflow would be the fragile
155
+ // choice — react-native-svg does not honour it consistently on Android.
156
+ <Svg
157
+ width={width}
158
+ height={height}
159
+ viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
160
+ fill="none"
161
+ {...props}
162
+ >
163
+ <G fill={fill}>
164
+ {DOT_X.map((_, index) => (
165
+ <Dot key={index} index={index} />
166
+ ))}
167
+ </G>
168
+ </Svg>
169
+ )
170
+ );
171
+
172
+ TypingDots.displayName = 'TypingDots';
@@ -0,0 +1,2 @@
1
+ export { TypingDots } from './TypingDots';
2
+ export type { TypingDotsProps } from './TypingDots';
@@ -0,0 +1,145 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { KColors, KContainer, KLabel } from '@droppii/libs';
4
+ import { trans } from '../../translation';
5
+ import {
6
+ COLLAPSED_LINES,
7
+ useExpandableText,
8
+ } from '../../hooks/useExpandableText';
9
+ import { truncateTextChildren } from '../../utils/truncateTextChildren';
10
+ import { PreviewScrollableText } from './PreviewScrollableText';
11
+ import type { MessageSurface } from '../../store/expandedMessage';
12
+
13
+ type TypoType = ComponentProps<typeof KLabel.Text>['typo'];
14
+
15
+ // Values from `@droppii/libs`' Typography.tsx (`generateTextStyle`'s third
16
+ // argument), not looked up at runtime — the package does not cleanly
17
+ // re-export a way to do that. All three call sites use `TextMdNormal` today;
18
+ // add an entry here before passing any other `typo` to this component.
19
+ const TYPO_LINE_HEIGHT: Record<string, number> = {
20
+ TextMdNormal: 25.6,
21
+ };
22
+ const DEFAULT_LINE_HEIGHT = 22.4;
23
+
24
+ // Figma (Live Chat, node 15389-207764): `…và... Xem thêm` — the ellipsis in
25
+ // the body colour, the control inline on the last visible line.
26
+ const ELLIPSIS = '... ';
27
+
28
+ interface ExpandableMessageTextProps {
29
+ messageId: string;
30
+ surface: MessageSurface;
31
+ /** Raw body text — drives the length/newline pre-check only. */
32
+ text: string;
33
+ /**
34
+ * The already-rendered body. Taken as children rather than re-derived from
35
+ * `text` because the three call sites build structurally different bodies
36
+ * (plain run, mention segments, linkified runs) and re-tokenising them here
37
+ * would duplicate three parsers.
38
+ */
39
+ children: ReactNode;
40
+ typo?: TypoType;
41
+ color?: string;
42
+ }
43
+
44
+ export const ExpandableMessageText = ({
45
+ messageId,
46
+ surface,
47
+ text,
48
+ children,
49
+ typo = 'TextMdNormal',
50
+ color,
51
+ }: ExpandableMessageTextProps) => {
52
+ const { mode, cut, expand, onMeasureTextLayout, onFitTextLayout } =
53
+ useExpandableText(messageId, text, surface);
54
+
55
+ if (mode === 'plain') {
56
+ // Only the overlay provides a budget; everywhere else this is a no-op.
57
+ return (
58
+ <PreviewScrollableText>
59
+ <KLabel.Text typo={typo} color={color}>
60
+ {children}
61
+ </KLabel.Text>
62
+ </PreviewScrollableText>
63
+ );
64
+ }
65
+
66
+ const label = trans('chat:info_view_more') || 'Xem thêm';
67
+
68
+ if (mode === 'measuring') {
69
+ // One layout pass. The visible copy is clipped to N lines by height so the
70
+ // bubble does not flash at full length; the hidden copy is unconstrained,
71
+ // which is what makes its `onTextLayout` report every line on iOS too.
72
+ const lineHeight =
73
+ TYPO_LINE_HEIGHT[typo ?? 'TextMdNormal'] ?? DEFAULT_LINE_HEIGHT;
74
+ return (
75
+ <KContainer.View>
76
+ <View
77
+ style={[
78
+ styles.clampedBox,
79
+ { maxHeight: lineHeight * COLLAPSED_LINES },
80
+ ]}
81
+ >
82
+ <KLabel.Text typo={typo} color={color}>
83
+ {children}
84
+ </KLabel.Text>
85
+ </View>
86
+ <View
87
+ style={styles.measuringCopy}
88
+ pointerEvents="none"
89
+ accessibilityElementsHidden
90
+ importantForAccessibility="no-hide-descendants"
91
+ >
92
+ <KLabel.Text
93
+ typo={typo}
94
+ color={color}
95
+ onTextLayout={onMeasureTextLayout}
96
+ >
97
+ {children}
98
+ </KLabel.Text>
99
+ </View>
100
+ </KContainer.View>
101
+ );
102
+ }
103
+
104
+ // 'fitting' re-checks that the suffix still fits in N lines; 'collapsed'
105
+ // renders a cut that already passed that check.
106
+ return (
107
+ <KLabel.Text
108
+ typo={typo}
109
+ color={color}
110
+ onTextLayout={mode === 'fitting' ? onFitTextLayout : undefined}
111
+ >
112
+ {truncateTextChildren(children, cut)}
113
+ {ELLIPSIS}
114
+ <KLabel.Text
115
+ typo="TextMdMedium"
116
+ color={KColors.primary.normal}
117
+ onPress={expand}
118
+ accessibilityRole="button"
119
+ accessibilityLabel={label}
120
+ >
121
+ {label}
122
+ </KLabel.Text>
123
+ </KLabel.Text>
124
+ );
125
+ };
126
+
127
+ ExpandableMessageText.displayName = 'ExpandableMessageText';
128
+
129
+ const styles = StyleSheet.create({
130
+ clampedBox: {
131
+ overflow: 'hidden',
132
+ },
133
+ // `left`+`right` take the container's width in the same layout pass, so the
134
+ // copy wraps exactly like the visible one without a round trip through
135
+ // `onLayout`. No `bottom`: pinning it (i.e. absoluteFill) would make this
136
+ // inherit the clamped parent's height, and iOS would lay out only the lines
137
+ // that fit — reporting a clamped message as short.
138
+ measuringCopy: {
139
+ opacity: 0,
140
+ position: 'absolute',
141
+ left: 0,
142
+ right: 0,
143
+ top: 0,
144
+ },
145
+ });
@@ -0,0 +1,98 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { ScrollView, View, type LayoutChangeEvent } from 'react-native';
10
+
11
+ /**
12
+ * Set by the toolbox overlay around its clone. `maxHeight` is the space the
13
+ * clone may take; `contentHeight` is how tall the clone currently is.
14
+ * `onScrollableChange` reports whether the text is capped (and so scrolls),
15
+ * because the overlay's tap-to-dismiss would otherwise block that scroll.
16
+ */
17
+ export interface ClonePreviewBudget {
18
+ maxHeight: number;
19
+ contentHeight: number;
20
+ onScrollableChange: (scrollable: boolean) => void;
21
+ }
22
+
23
+ export const ClonePreviewBudgetContext =
24
+ createContext<ClonePreviewBudget | null>(null);
25
+
26
+ // Never squeeze the text below three lines to make room for a tall card.
27
+ const MIN_TEXT_HEIGHT = 25.6 * 3;
28
+
29
+ /**
30
+ * An expanded message can be taller than the overlay. Scrolling the whole
31
+ * clone clips the bubble along a straight edge — square corners — so inside
32
+ * the overlay the text region scrolls instead and the bubble keeps its shape.
33
+ *
34
+ * Budget: whatever the rest of the clone (padding, quote strip, link card,
35
+ * time row) does not use. `contentHeight` and this box's height arrive from
36
+ * native at different moments, and mixing a fresh one with a stale one makes
37
+ * the cap swing between two sizes indefinitely. So the rest is read only from
38
+ * a consistent pair — while uncapped, when the box equals the natural height —
39
+ * and frozen once a cap is applied, which breaks the feedback loop.
40
+ */
41
+ export const PreviewScrollableText = ({
42
+ children,
43
+ }: {
44
+ children: ReactNode;
45
+ }) => {
46
+ const budget = useContext(ClonePreviewBudgetContext);
47
+ const [naturalHeight, setNaturalHeight] = useState(0);
48
+ const [boxHeight, setBoxHeight] = useState(0);
49
+ const [frozenRest, setFrozenRest] = useState<number | null>(null);
50
+
51
+ const onBoxLayout = useCallback((e: LayoutChangeEvent) => {
52
+ setBoxHeight(e.nativeEvent.layout.height);
53
+ }, []);
54
+ const onContentLayout = useCallback((e: LayoutChangeEvent) => {
55
+ setNaturalHeight(e.nativeEvent.layout.height);
56
+ }, []);
57
+
58
+ let rest = frozenRest;
59
+ const uncappedPairIsConsistent =
60
+ !!budget &&
61
+ naturalHeight > 0 &&
62
+ Math.abs(boxHeight - naturalHeight) <= 1 &&
63
+ budget.contentHeight >= naturalHeight;
64
+ if (budget && rest == null && uncappedPairIsConsistent) {
65
+ rest = budget.contentHeight - naturalHeight;
66
+ }
67
+
68
+ let maxHeight: number | undefined;
69
+ if (budget && rest != null) {
70
+ const allowed = budget.maxHeight - rest;
71
+ if (naturalHeight > allowed + 1) {
72
+ maxHeight = Math.max(allowed, MIN_TEXT_HEIGHT);
73
+ if (frozenRest == null) setFrozenRest(rest);
74
+ }
75
+ }
76
+
77
+ const scrollable = maxHeight != null;
78
+ const onScrollableChange = budget?.onScrollableChange;
79
+ useEffect(() => {
80
+ if (!onScrollableChange) return;
81
+ onScrollableChange(scrollable);
82
+ return () => onScrollableChange(false);
83
+ }, [onScrollableChange, scrollable]);
84
+
85
+ if (!budget) return <>{children}</>;
86
+
87
+ return (
88
+ <ScrollView
89
+ style={maxHeight != null ? { maxHeight } : undefined}
90
+ onLayout={onBoxLayout}
91
+ nestedScrollEnabled
92
+ bounces={false}
93
+ showsVerticalScrollIndicator={false}
94
+ >
95
+ <View onLayout={onContentLayout}>{children}</View>
96
+ </ScrollView>
97
+ );
98
+ };
@@ -10,35 +10,37 @@ import { LinkPreviewCard } from './LinkPreviewCard';
10
10
  import { getTailCornerStyle } from '../bubbleTail.utils';
11
11
  import { BubbleTailRow } from '../BubbleTailRow';
12
12
  import { LINK_URL_COLOR } from '../../../utils/theme';
13
+ import { ExpandableMessageText } from '../ExpandableMessageText';
13
14
 
15
+ // Returns the inner runs only — the wrapping <Text> comes from
16
+ // ExpandableMessageText, which is the node the line clamp has to land on.
14
17
  const renderTextWithLinks = (
15
18
  content: string,
16
19
  urls: string[],
17
20
  onPressUrl?: (url: string) => void
18
- ) => (
19
- <KLabel.Text typo="TextMdNormal">
20
- {splitTextByUrls(content, urls).map((part, i) =>
21
- part.isUrl ? (
22
- <KLabel.Text
23
- key={i}
24
- onPress={() => onPressUrl?.(part.text)}
25
- color={LINK_URL_COLOR}
26
- typo="TextMdMedium"
27
- >
28
- {part.text}
29
- </KLabel.Text>
30
- ) : (
31
- <KLabel.Text typo="TextMdNormal" key={i}>
32
- {part.text}
33
- </KLabel.Text>
34
- )
35
- )}
36
- </KLabel.Text>
37
- );
21
+ ) =>
22
+ splitTextByUrls(content, urls).map((part, i) =>
23
+ part.isUrl ? (
24
+ <KLabel.Text
25
+ key={i}
26
+ onPress={() => onPressUrl?.(part.text)}
27
+ color={LINK_URL_COLOR}
28
+ typo="TextMdMedium"
29
+ >
30
+ {part.text}
31
+ </KLabel.Text>
32
+ ) : (
33
+ <KLabel.Text typo="TextMdNormal" key={i}>
34
+ {part.text}
35
+ </KLabel.Text>
36
+ )
37
+ );
38
38
 
39
39
  export const LinkMessage = memo(
40
40
  ({
41
41
  message,
42
+ messageId,
43
+ surface,
42
44
  isOutgoing,
43
45
  createdAtTime,
44
46
  onPressUrl,
@@ -64,7 +66,14 @@ export const LinkMessage = memo(
64
66
  >
65
67
  {!!content?.trim() && (
66
68
  <KContainer.View style={styles.textRow}>
67
- {renderTextWithLinks(content, urls, onPressUrl)}
69
+ <ExpandableMessageText
70
+ messageId={messageId}
71
+ surface={surface}
72
+ text={content}
73
+ typo="TextMdNormal"
74
+ >
75
+ {renderTextWithLinks(content, urls, onPressUrl)}
76
+ </ExpandableMessageText>
68
77
  </KContainer.View>
69
78
  )}
70
79
 
@@ -22,6 +22,7 @@ import {
22
22
  import { getFileIcon, getMimeType } from '../../../utils/imageUtils';
23
23
  import { extractUrls } from '../../../utils/url';
24
24
  import { renderLinkifiedText } from '../LinkifiedText';
25
+ import { ExpandableMessageText } from '../ExpandableMessageText';
25
26
  import { useUserStore } from '../../../store';
26
27
  import { useChatDetailContext } from '../../../context';
27
28
  import { ChatEvent } from '../../../types/events';
@@ -33,6 +34,9 @@ interface QuotedMessageProps extends BaseLegendMessageProps {
33
34
  const QuotedMessage = memo((props: QuotedMessageProps) => {
34
35
  const {
35
36
  message,
37
+ messageId,
38
+ surface,
39
+ isPreview,
36
40
  isOutgoing,
37
41
  createdAtTime,
38
42
  isHighlighted,
@@ -104,6 +108,8 @@ const QuotedMessage = memo((props: QuotedMessageProps) => {
104
108
  <KContainer.Touchable
105
109
  style={styles.quotedContainer}
106
110
  onPress={handlePressQuotedMessage}
111
+ // Would scroll the timeline hidden behind the overlay.
112
+ disabled={isPreview}
107
113
  >
108
114
  <KContainer.View style={styles.dot} />
109
115
  <KContainer.VisibleView visible={!!imageUrl}>
@@ -152,9 +158,15 @@ const QuotedMessage = memo((props: QuotedMessageProps) => {
152
158
  </KLabel.Text>
153
159
  </KContainer.View>
154
160
  </KContainer.Touchable>
155
- <KLabel.Text typo="TextMdNormal" color={CHAT_BUBBLE_COLORS.text}>
161
+ <ExpandableMessageText
162
+ messageId={messageId}
163
+ surface={surface}
164
+ text={replyText}
165
+ typo="TextMdNormal"
166
+ color={CHAT_BUBBLE_COLORS.text}
167
+ >
156
168
  {renderLinkifiedText(replyText, urls, onPressUrl, 'reply')}
157
- </KLabel.Text>
169
+ </ExpandableMessageText>
158
170
  </KContainer.View>
159
171
  </BubbleTailRow>
160
172
 
@@ -15,6 +15,7 @@ import { QuotedMessagePreviewStrip } from '../quotedMessage/QuotedMessagePreview
15
15
  import { LinkPreviewCard } from '../linkMessage/LinkPreviewCard';
16
16
  import { extractUrls } from '../../../utils/url';
17
17
  import { renderLinkifiedText } from '../LinkifiedText';
18
+ import { ExpandableMessageText } from '../ExpandableMessageText';
18
19
 
19
20
  const parseMessageEx = (ex?: string): IMessageItemEx | undefined => {
20
21
  if (!ex) return undefined;
@@ -28,6 +29,8 @@ const parseMessageEx = (ex?: string): IMessageItemEx | undefined => {
28
29
  export const TextMessage = memo(
29
30
  ({
30
31
  message,
32
+ messageId,
33
+ surface,
31
34
  isOutgoing,
32
35
  createdAtTime,
33
36
  isHighlighted,
@@ -121,7 +124,13 @@ export const TextMessage = memo(
121
124
  {quotedMessage && (
122
125
  <QuotedMessagePreviewStrip quotedMessage={quotedMessage} />
123
126
  )}
124
- <KLabel.Text typo="TextMdNormal" color={CHAT_BUBBLE_COLORS.text}>
127
+ <ExpandableMessageText
128
+ messageId={messageId}
129
+ surface={surface}
130
+ text={messageText}
131
+ typo="TextMdNormal"
132
+ color={CHAT_BUBBLE_COLORS.text}
133
+ >
125
134
  {segments
126
135
  ? segments.map((segment, index) =>
127
136
  segment.type === 'mention' ? (
@@ -133,7 +142,7 @@ export const TextMessage = memo(
133
142
  )
134
143
  )
135
144
  : messageText}
136
- </KLabel.Text>
145
+ </ExpandableMessageText>
137
146
  {!!urlMetadata && (
138
147
  <LinkPreviewCard metadata={urlMetadata} onPressUrl={onPressUrl} />
139
148
  )}
@@ -1,9 +1,27 @@
1
1
  import type { MediaItem } from '../../hooks/useMediaViewer';
2
2
  import type { MessageItem } from '@droppii/openim-rn-client-sdk';
3
3
  import type { GroupPosition } from '../../utils/legendListMessage';
4
+ import type { MessageSurface } from '../../store/expandedMessage';
4
5
 
5
6
  export interface BaseLegendMessageProps {
6
7
  message: MessageItem;
8
+ /**
9
+ * Resolved once by the caller and threaded down — never re-derived here.
10
+ * The timeline, the pin sheet and the toolbox overlay each compute a
11
+ * message's id differently, and a mismatch silently splits its expansion
12
+ * state across surfaces.
13
+ */
14
+ messageId: string;
15
+ /** Expansion is scoped per surface (BR-003): the same message collapsed in
16
+ * Pin Management while expanded in the timeline, and vice versa. */
17
+ surface: MessageSurface;
18
+ /**
19
+ * Rendered as the toolbox overlay's clone. The clone is touchable so that
20
+ * `Xem thêm` works there (D1), which makes every other press target in the
21
+ * bubble live too — components with a self-contained handler must disable
22
+ * it when this is set.
23
+ */
24
+ isPreview?: boolean;
7
25
  isOutgoing: boolean;
8
26
  createdAtTime: number;
9
27
  isHighlighted?: boolean;