@droppii-org/chat-mobile 0.2.106 → 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 (81) hide show
  1. package/lib/module/components/messages/ExpandableMessageText.js +112 -0
  2. package/lib/module/components/messages/ExpandableMessageText.js.map +1 -0
  3. package/lib/module/components/messages/PreviewScrollableText.js +80 -0
  4. package/lib/module/components/messages/PreviewScrollableText.js.map +1 -0
  5. package/lib/module/components/messages/linkMessage/index.js +21 -12
  6. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  7. package/lib/module/components/messages/quotedMessage/index.js +12 -2
  8. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  9. package/lib/module/components/messages/textMessage/index.js +7 -1
  10. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  11. package/lib/module/components/messages/types.js.map +1 -1
  12. package/lib/module/hooks/useExpandableText.js +120 -0
  13. package/lib/module/hooks/useExpandableText.js.map +1 -0
  14. package/lib/module/screens/chat-detail/ChatDetail.js +11 -0
  15. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  16. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +7 -0
  17. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  18. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +10 -3
  19. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  20. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +11 -2
  21. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  22. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +79 -6
  23. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -1
  24. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js +30 -0
  25. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js.map +1 -0
  26. package/lib/module/store/expandedMessage.js +28 -0
  27. package/lib/module/store/expandedMessage.js.map +1 -0
  28. package/lib/module/utils/expandableText.js +41 -0
  29. package/lib/module/utils/expandableText.js.map +1 -0
  30. package/lib/module/utils/inlineTruncation.js +51 -0
  31. package/lib/module/utils/inlineTruncation.js.map +1 -0
  32. package/lib/module/utils/truncateTextChildren.js +38 -0
  33. package/lib/module/utils/truncateTextChildren.js.map +1 -0
  34. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts +25 -0
  35. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts.map +1 -0
  36. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts +29 -0
  37. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts.map +1 -0
  38. package/lib/typescript/src/components/messages/linkMessage/index.d.ts +1 -1
  39. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/messages/textMessage/index.d.ts +1 -1
  42. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/messages/types.d.ts +18 -0
  44. package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
  45. package/lib/typescript/src/hooks/useExpandableText.d.ts +13 -0
  46. package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -0
  47. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  48. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  49. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +11 -1
  50. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  51. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  52. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +14 -1
  53. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -1
  54. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts +18 -0
  55. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts.map +1 -0
  56. package/lib/typescript/src/store/expandedMessage.d.ts +15 -0
  57. package/lib/typescript/src/store/expandedMessage.d.ts.map +1 -0
  58. package/lib/typescript/src/utils/expandableText.d.ts +18 -0
  59. package/lib/typescript/src/utils/expandableText.d.ts.map +1 -0
  60. package/lib/typescript/src/utils/inlineTruncation.d.ts +24 -0
  61. package/lib/typescript/src/utils/inlineTruncation.d.ts.map +1 -0
  62. package/lib/typescript/src/utils/truncateTextChildren.d.ts +3 -0
  63. package/lib/typescript/src/utils/truncateTextChildren.d.ts.map +1 -0
  64. package/package.json +1 -1
  65. package/src/components/messages/ExpandableMessageText.tsx +145 -0
  66. package/src/components/messages/PreviewScrollableText.tsx +98 -0
  67. package/src/components/messages/linkMessage/index.tsx +30 -21
  68. package/src/components/messages/quotedMessage/index.tsx +14 -2
  69. package/src/components/messages/textMessage/index.tsx +11 -2
  70. package/src/components/messages/types.ts +18 -0
  71. package/src/hooks/useExpandableText.ts +139 -0
  72. package/src/screens/chat-detail/ChatDetail.tsx +11 -0
  73. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +8 -0
  74. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +22 -2
  75. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -1
  76. package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +111 -5
  77. package/src/screens/chat-detail/messageToolbox/useClonePreview.ts +34 -0
  78. package/src/store/expandedMessage.ts +42 -0
  79. package/src/utils/expandableText.ts +51 -0
  80. package/src/utils/inlineTruncation.ts +65 -0
  81. package/src/utils/truncateTextChildren.ts +46 -0
@@ -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
+ );
@@ -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,46 @@
1
+ import { cloneElement, isValidElement, type ReactNode } from 'react';
2
+
3
+ type Taken = [node: ReactNode, used: number];
4
+
5
+ /**
6
+ * Walks a `<Text>` children tree — plain strings, mention runs, linkified
7
+ * runs, nested `<Text>` — and keeps the first `budget` characters of rendered
8
+ * text, preserving every element's styling and press handler up to the cut.
9
+ * Character counts line up with `onTextLayout` because RN renders exactly the
10
+ * concatenation of the string leaves.
11
+ */
12
+ const take = (node: ReactNode, budget: number): Taken => {
13
+ if (budget <= 0 || node == null || typeof node === 'boolean') {
14
+ return [null, 0];
15
+ }
16
+
17
+ if (typeof node === 'string' || typeof node === 'number') {
18
+ const text = String(node);
19
+ return text.length <= budget
20
+ ? [node, text.length]
21
+ : [text.slice(0, budget), budget];
22
+ }
23
+
24
+ if (Array.isArray(node)) {
25
+ const kept: ReactNode[] = [];
26
+ let used = 0;
27
+ for (const child of node) {
28
+ if (used >= budget) break;
29
+ const [part, count] = take(child, budget - used);
30
+ kept.push(part);
31
+ used += count;
32
+ }
33
+ return [kept, used];
34
+ }
35
+
36
+ if (isValidElement<{ children?: ReactNode }>(node)) {
37
+ if (node.props.children === undefined) return [node, 0];
38
+ const [children, used] = take(node.props.children, budget);
39
+ return [cloneElement(node, undefined, children), used];
40
+ }
41
+
42
+ return [node, 0];
43
+ };
44
+
45
+ export const truncateTextChildren = (node: ReactNode, limit: number) =>
46
+ take(node, limit)[0];