@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.
- package/lib/module/components/messages/ExpandableMessageText.js +112 -0
- package/lib/module/components/messages/ExpandableMessageText.js.map +1 -0
- package/lib/module/components/messages/PreviewScrollableText.js +80 -0
- package/lib/module/components/messages/PreviewScrollableText.js.map +1 -0
- package/lib/module/components/messages/linkMessage/index.js +21 -12
- package/lib/module/components/messages/linkMessage/index.js.map +1 -1
- package/lib/module/components/messages/quotedMessage/index.js +12 -2
- package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
- package/lib/module/components/messages/textMessage/index.js +7 -1
- package/lib/module/components/messages/textMessage/index.js.map +1 -1
- package/lib/module/components/messages/types.js.map +1 -1
- package/lib/module/hooks/useExpandableText.js +120 -0
- package/lib/module/hooks/useExpandableText.js.map +1 -0
- package/lib/module/screens/chat-detail/ChatDetail.js +11 -0
- package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
- package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +7 -0
- package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
- package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +10 -3
- package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +11 -2
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +79 -6
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js +30 -0
- package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js.map +1 -0
- package/lib/module/store/expandedMessage.js +28 -0
- package/lib/module/store/expandedMessage.js.map +1 -0
- package/lib/module/utils/expandableText.js +41 -0
- package/lib/module/utils/expandableText.js.map +1 -0
- package/lib/module/utils/inlineTruncation.js +51 -0
- package/lib/module/utils/inlineTruncation.js.map +1 -0
- package/lib/module/utils/truncateTextChildren.js +38 -0
- package/lib/module/utils/truncateTextChildren.js.map +1 -0
- package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts +25 -0
- package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts.map +1 -0
- package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts +29 -0
- package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts.map +1 -0
- package/lib/typescript/src/components/messages/linkMessage/index.d.ts +1 -1
- package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/textMessage/index.d.ts +1 -1
- package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/types.d.ts +18 -0
- package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useExpandableText.d.ts +13 -0
- package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +11 -1
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +14 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts +18 -0
- package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts.map +1 -0
- package/lib/typescript/src/store/expandedMessage.d.ts +15 -0
- package/lib/typescript/src/store/expandedMessage.d.ts.map +1 -0
- package/lib/typescript/src/utils/expandableText.d.ts +18 -0
- package/lib/typescript/src/utils/expandableText.d.ts.map +1 -0
- package/lib/typescript/src/utils/inlineTruncation.d.ts +24 -0
- package/lib/typescript/src/utils/inlineTruncation.d.ts.map +1 -0
- package/lib/typescript/src/utils/truncateTextChildren.d.ts +3 -0
- package/lib/typescript/src/utils/truncateTextChildren.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/messages/ExpandableMessageText.tsx +145 -0
- package/src/components/messages/PreviewScrollableText.tsx +98 -0
- package/src/components/messages/linkMessage/index.tsx +30 -21
- package/src/components/messages/quotedMessage/index.tsx +14 -2
- package/src/components/messages/textMessage/index.tsx +11 -2
- package/src/components/messages/types.ts +18 -0
- package/src/hooks/useExpandableText.ts +139 -0
- package/src/screens/chat-detail/ChatDetail.tsx +11 -0
- package/src/screens/chat-detail/PinnedMessagesSheet.tsx +8 -0
- package/src/screens/chat-detail/legend/LegendChatMessage.tsx +22 -2
- package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -1
- package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +111 -5
- package/src/screens/chat-detail/messageToolbox/useClonePreview.ts +34 -0
- package/src/store/expandedMessage.ts +42 -0
- package/src/utils/expandableText.ts +51 -0
- package/src/utils/inlineTruncation.ts +65 -0
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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={
|
|
259
|
+
messageId={overlayMessageId}
|
|
260
|
+
isPreview
|
|
242
261
|
isOutgoing={isOutgoing}
|
|
243
262
|
createdAtTime={message.sendTime || message.createTime}
|
|
244
263
|
customMessageItemFactory={customMessageItemFactory}
|