@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
@@ -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;
@@ -0,0 +1,139 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { useWindowDimensions, type TextLayoutEvent } from 'react-native';
3
+ import { useExpandedMessageStore } from '../store/expandedMessage';
4
+ import {
5
+ COLLAPSED_LINES,
6
+ expandedKey,
7
+ isMeasurementCandidate,
8
+ type MessageSurface,
9
+ } from '../utils/expandableText';
10
+ import { initialCut, joinLines, shrinkCut } from '../utils/inlineTruncation';
11
+
12
+ export { COLLAPSED_LINES };
13
+
14
+ type Verdict = { truncated: false } | { truncated: true; cut: number };
15
+
16
+ // Each pass is one extra layout; in practice the suffix fits by the second.
17
+ // Past this the suffix may sit on a sixth line — still visible and tappable.
18
+ const MAX_FIT_PASSES = 4;
19
+
20
+ /**
21
+ * Truncation is a pure function of (text, available width, typography), so it
22
+ * belongs to the message — not to the list row that happens to render it.
23
+ * `recycleItems` reuses row instances, so a verdict held in component state
24
+ * bleeds onto the next message in that slot. Keyed here, a recycled row reads
25
+ * the right answer during render and pays no measurement for a message seen.
26
+ */
27
+ const verdictCache = new Map<string, Verdict>();
28
+
29
+ export const clearExpandableTextCache = () => verdictCache.clear();
30
+
31
+ // Window width, not the bubble's: the bubble shrinks to its content, and the
32
+ // content changes when the suffix is appended, so keying on it would re-measure
33
+ // in a loop. Surface is in the key because the pin sheet is a different width.
34
+ // Text length makes a body that mutates in place (TextMessage re-derives its
35
+ // runs when `ex` arrives) miss the cache rather than reuse a stale cut.
36
+ const verdictKey = (
37
+ surface: MessageSurface,
38
+ messageId: string,
39
+ windowWidth: number,
40
+ text: string
41
+ ) => `${surface}:${messageId}:${Math.round(windowWidth)}:${text.length}`;
42
+
43
+ interface Fit {
44
+ key: string | null;
45
+ full?: string;
46
+ cut?: number;
47
+ passes: number;
48
+ verdict?: Verdict;
49
+ }
50
+
51
+ export type ExpandableTextMode =
52
+ | 'plain'
53
+ | 'measuring'
54
+ | 'fitting'
55
+ | 'collapsed';
56
+
57
+ export function useExpandableText(
58
+ messageId: string,
59
+ text: string,
60
+ surface: MessageSurface
61
+ ) {
62
+ const expanded = useExpandedMessageStore(
63
+ (s) => !!s.expanded[expandedKey(surface, messageId)]
64
+ );
65
+ const expandInStore = useExpandedMessageStore((s) => s.expand);
66
+ const { width: windowWidth } = useWindowDimensions();
67
+
68
+ const key = messageId
69
+ ? verdictKey(surface, messageId, windowWidth, text)
70
+ : null;
71
+
72
+ // Reset during render rather than in an effect: an effect lands after paint,
73
+ // so a recycled row would show one frame with the previous message's cut.
74
+ const [fit, setFit] = useState<Fit>({ key, passes: 0 });
75
+ const current = fit.key === key ? fit : { key, passes: 0 };
76
+ if (fit.key !== key) setFit(current);
77
+
78
+ // Without an id there is nowhere to record an expansion, so the control
79
+ // would do nothing. Never collapse those.
80
+ const isCandidate = !!messageId && !expanded && isMeasurementCandidate(text);
81
+ const verdict = (key && verdictCache.get(key)) || current.verdict;
82
+
83
+ let mode: ExpandableTextMode = 'plain';
84
+ if (isCandidate) {
85
+ if (verdict) mode = verdict.truncated ? 'collapsed' : 'plain';
86
+ else mode = current.cut == null ? 'measuring' : 'fitting';
87
+ }
88
+ const cut = verdict?.truncated ? verdict.cut : (current.cut ?? 0);
89
+
90
+ const settle = useCallback(
91
+ (next: Verdict) => {
92
+ if (key) verdictCache.set(key, next);
93
+ setFit((prev) => (prev.key === key ? { ...prev, verdict: next } : prev));
94
+ },
95
+ [key]
96
+ );
97
+
98
+ // Full, unclamped copy: how many lines, and where line N ends.
99
+ const onMeasureTextLayout = useCallback(
100
+ ({ nativeEvent: { lines } }: TextLayoutEvent) => {
101
+ if (lines.length <= COLLAPSED_LINES) {
102
+ settle({ truncated: false });
103
+ return;
104
+ }
105
+ const full = joinLines(lines);
106
+ const first = initialCut(lines, COLLAPSED_LINES);
107
+ setFit((prev) =>
108
+ prev.key === key ? { ...prev, full, cut: first, passes: 0 } : prev
109
+ );
110
+ },
111
+ [key, settle]
112
+ );
113
+
114
+ // The visible copy with the suffix appended: accept once it fits in N lines.
115
+ const onFitTextLayout = useCallback(
116
+ ({ nativeEvent: { lines } }: TextLayoutEvent) => {
117
+ if (fit.key !== key || fit.full == null || fit.cut == null) return;
118
+ if (lines.length <= COLLAPSED_LINES || fit.passes >= MAX_FIT_PASSES) {
119
+ settle({ truncated: true, cut: fit.cut });
120
+ return;
121
+ }
122
+ const next = shrinkCut(fit.full, fit.cut, lines, COLLAPSED_LINES);
123
+ setFit({ ...fit, cut: next, passes: fit.passes + 1 });
124
+ },
125
+ [fit, key, settle]
126
+ );
127
+
128
+ const expand = useCallback(() => {
129
+ expandInStore(surface, messageId);
130
+ }, [expandInStore, surface, messageId]);
131
+
132
+ return {
133
+ mode,
134
+ cut,
135
+ expand,
136
+ onMeasureTextLayout,
137
+ onFitTextLayout,
138
+ };
139
+ }
@@ -18,6 +18,8 @@ import {
18
18
  useConversationStore,
19
19
  useUserStore,
20
20
  } from '../../store';
21
+ import { useExpandedMessageStore } from '../../store/expandedMessage';
22
+ import { clearExpandableTextCache } from '../../hooks/useExpandableText';
21
23
  import ChatDetailHeader from './ChatDetailHeader';
22
24
  import { ChatListLegend } from './ChatListLegend';
23
25
  import { ChatComposer } from './ChatComposer';
@@ -81,6 +83,15 @@ const ChatDetail = memo(
81
83
 
82
84
  const conversation = useConversation(conversationId);
83
85
 
86
+ // Keyed on the conversation, not on unmount: this screen is mounted by the
87
+ // consumer's navigator, so whether it remounts per conversation or is
88
+ // reused with a new id is not knowable here. Reopening a conversation must
89
+ // start every message shortened again.
90
+ useEffect(() => {
91
+ useExpandedMessageStore.getState().clearSurface('timeline');
92
+ clearExpandableTextCache();
93
+ }, [conversationId]);
94
+
84
95
  const { data: target } = useGetConversationTarget(conversationId);
85
96
  const isComposeDisabled = !!target && !target.isActive;
86
97
 
@@ -12,6 +12,7 @@ import {
12
12
  import { UIUtils } from '../../utils/UIUtils';
13
13
  import { trans } from '../../translation';
14
14
  import { usePinnedMessageStore } from '../../store/pinnedMessage';
15
+ import { useExpandedMessageStore } from '../../store/expandedMessage';
15
16
  import { LegendChatMessage } from './legend/LegendChatMessage';
16
17
  import { formatMessageTime } from './legend/messageTypes';
17
18
 
@@ -122,6 +123,7 @@ const PinnedMessagesSheetContent = memo(
122
123
  <LegendChatMessage
123
124
  message={message}
124
125
  messageId={messageId}
126
+ surface="pin"
125
127
  isOutgoing={isOutgoing}
126
128
  createdAtTime={createdAtTime}
127
129
  onPress={handlePressMessage}
@@ -198,6 +200,12 @@ export const openPinnedMessagesSheet = ({
198
200
  customMessageItemFactory,
199
201
  onPressMessage,
200
202
  }: OpenPinnedMessagesSheetParams) => {
203
+ // Reset on open, not on close. `UIUtils.bottomSheet` is an adapter the host
204
+ // app registers, so whether dismissing unmounts this content — or keeps it
205
+ // mounted and hidden — is not ours to know. Clearing here satisfies
206
+ // "reopened starts shortened" whatever the host does with the sheet.
207
+ useExpandedMessageStore.getState().clearSurface('pin');
208
+
201
209
  UIUtils.bottomSheet.open({
202
210
  header: {
203
211
  title: { text: trans('chat:pinned_messages_title') },
@@ -28,6 +28,7 @@ import QuotedMessage from '../../../components/messages/quotedMessage';
28
28
  import { StickerMessage } from '../../../components/messages/stickerMessage';
29
29
  import { GROUP_NOTIFICATION_TYPES } from '../../../utils/messageUtils';
30
30
  import type { GroupPosition } from '../../../utils/legendListMessage';
31
+ import type { MessageSurface } from '../../../store/expandedMessage';
31
32
  import { Avatar } from '../../../components/Avatar';
32
33
  import { TAIL_WIDTH } from '../../../components/messages/BubbleTail';
33
34
 
@@ -72,6 +73,15 @@ interface LegendChatMessageProps {
72
73
  showSenderGutter?: boolean;
73
74
  /** Position within its same-sender cluster — drives the bubble tail. */
74
75
  groupPosition?: GroupPosition;
76
+ /**
77
+ * Which surface is rendering this message. Expansion state is scoped by it
78
+ * (BR-003), so Pin Management must pass `'pin'` explicitly — the default
79
+ * keeps the timeline and the toolbox overlay sharing one scope, which is
80
+ * what makes the overlay clone mirror the row it lifted from.
81
+ */
82
+ surface?: MessageSurface;
83
+ /** Toolbox overlay clone — see `BaseLegendMessageProps.isPreview`. */
84
+ isPreview?: boolean;
75
85
  }
76
86
 
77
87
  const messageComponentMap: Partial<
@@ -129,6 +139,8 @@ export const LegendChatMessage = memo(
129
139
  showAvatar = false,
130
140
  showSenderGutter = false,
131
141
  groupPosition,
142
+ surface = 'timeline',
143
+ isPreview,
132
144
  }: LegendChatMessageProps) => {
133
145
  const handlePress = useCallback(() => {
134
146
  onPress?.(messageId);
@@ -151,6 +163,8 @@ export const LegendChatMessage = memo(
151
163
  <KContainer.View center style={styles.systemMessageWrapper}>
152
164
  <SystemMessageComponent
153
165
  message={message}
166
+ messageId={messageId}
167
+ surface={surface}
154
168
  isOutgoing={isOutgoing}
155
169
  createdAtTime={createdAtTime}
156
170
  />
@@ -210,12 +224,18 @@ export const LegendChatMessage = memo(
210
224
  {message.senderNickname}
211
225
  </KLabel.Text>
212
226
  )}
227
+ {/* No gestures on the overlay clone: a Tap here would race
228
+ `Xem thêm` for the same touch, and the overlay has its own
229
+ tap-to-dismiss around the clone. */}
213
230
  <LongPressWrapper
214
- onPress={shouldHandlePress ? handlePress : undefined}
215
- onLongPress={handleLongPress}
231
+ onPress={shouldHandlePress && !isPreview ? handlePress : undefined}
232
+ onLongPress={isPreview ? undefined : handleLongPress}
216
233
  >
217
234
  <MessageComponent
218
235
  message={message}
236
+ messageId={messageId}
237
+ surface={surface}
238
+ isPreview={isPreview}
219
239
  isOutgoing={isOutgoing}
220
240
  createdAtTime={createdAtTime}
221
241
  onMediaPress={hasMediaPress ? effectiveOnMediaPress : undefined}
@@ -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}