@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.
- package/lib/module/components/TypingDots/TypingDots.js +131 -0
- package/lib/module/components/TypingDots/TypingDots.js.map +1 -0
- package/lib/module/components/TypingDots/index.js +4 -0
- package/lib/module/components/TypingDots/index.js.map +1 -0
- 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/core/useChatListener.js +26 -2
- package/lib/module/core/useChatListener.js.map +1 -1
- package/lib/module/hooks/groups/useGroupMemberName.js +32 -0
- package/lib/module/hooks/groups/useGroupMemberName.js.map +1 -0
- package/lib/module/hooks/query-keys.js +2 -1
- package/lib/module/hooks/query-keys.js.map +1 -1
- package/lib/module/hooks/useExpandableText.js +120 -0
- package/lib/module/hooks/useExpandableText.js.map +1 -0
- package/lib/module/hooks/useInputState.js +38 -0
- package/lib/module/hooks/useInputState.js.map +1 -0
- package/lib/module/hooks/useInputStateBroadcast.js +53 -0
- package/lib/module/hooks/useInputStateBroadcast.js.map +1 -0
- package/lib/module/screens/chat-detail/ChatComposer.js +15 -3
- package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
- 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/ChatListLegend.js +2 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatTypingIndicator.js +86 -0
- package/lib/module/screens/chat-detail/ChatTypingIndicator.js.map +1 -0
- 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/store/index.js +1 -0
- package/lib/module/store/index.js.map +1 -1
- package/lib/module/store/inputState.js +64 -0
- package/lib/module/store/inputState.js.map +1 -0
- package/lib/module/translation/resources/i18n.js +2 -0
- package/lib/module/translation/resources/i18n.js.map +1 -1
- 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/inputState.js +77 -0
- package/lib/module/utils/inputState.js.map +1 -0
- package/lib/module/utils/message.js +9 -0
- package/lib/module/utils/message.js.map +1 -1
- package/lib/module/utils/truncateTextChildren.js +38 -0
- package/lib/module/utils/truncateTextChildren.js.map +1 -0
- package/lib/typescript/src/components/TypingDots/TypingDots.d.ts +14 -0
- package/lib/typescript/src/components/TypingDots/TypingDots.d.ts.map +1 -0
- package/lib/typescript/src/components/TypingDots/index.d.ts +3 -0
- package/lib/typescript/src/components/TypingDots/index.d.ts.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/core/useChatListener.d.ts.map +1 -1
- package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts +10 -0
- package/lib/typescript/src/hooks/groups/useGroupMemberName.d.ts.map +1 -0
- package/lib/typescript/src/hooks/query-keys.d.ts +1 -0
- package/lib/typescript/src/hooks/query-keys.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/hooks/useInputState.d.ts +21 -0
- package/lib/typescript/src/hooks/useInputState.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts +14 -0
- package/lib/typescript/src/hooks/useInputStateBroadcast.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts +8 -0
- package/lib/typescript/src/screens/chat-detail/ChatTypingIndicator.d.ts.map +1 -0
- 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/store/index.d.ts +1 -0
- package/lib/typescript/src/store/index.d.ts.map +1 -1
- package/lib/typescript/src/store/inputState.d.ts +18 -0
- package/lib/typescript/src/store/inputState.d.ts.map +1 -0
- package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
- 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/inputState.d.ts +54 -0
- package/lib/typescript/src/utils/inputState.d.ts.map +1 -0
- package/lib/typescript/src/utils/message.d.ts +8 -0
- package/lib/typescript/src/utils/message.d.ts.map +1 -1
- 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/TypingDots/TypingDots.tsx +172 -0
- package/src/components/TypingDots/index.ts +2 -0
- 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/core/useChatListener.ts +35 -1
- package/src/hooks/groups/useGroupMemberName.ts +31 -0
- package/src/hooks/query-keys.ts +2 -0
- package/src/hooks/useExpandableText.ts +139 -0
- package/src/hooks/useInputState.ts +57 -0
- package/src/hooks/useInputStateBroadcast.ts +53 -0
- package/src/screens/chat-detail/ChatComposer.tsx +17 -2
- package/src/screens/chat-detail/ChatDetail.tsx +11 -0
- package/src/screens/chat-detail/ChatListLegend.tsx +2 -0
- package/src/screens/chat-detail/ChatTypingIndicator.tsx +89 -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/store/index.ts +1 -0
- package/src/store/inputState.ts +76 -0
- package/src/translation/resources/i18n.ts +2 -0
- package/src/utils/expandableText.ts +51 -0
- package/src/utils/inlineTruncation.ts +65 -0
- package/src/utils/inputState.ts +104 -0
- package/src/utils/message.ts +15 -0
- package/src/utils/truncateTextChildren.ts +46 -0
|
@@ -7,6 +7,7 @@ import OpenIMSDK, {
|
|
|
7
7
|
type ConversationItem,
|
|
8
8
|
type GroupApplicationItem,
|
|
9
9
|
type GroupItem,
|
|
10
|
+
type ConversationInputStatus,
|
|
10
11
|
type GroupMemberItem,
|
|
11
12
|
type MessageItem,
|
|
12
13
|
type MessagePinned,
|
|
@@ -25,7 +26,11 @@ import { useChatContext } from '../context';
|
|
|
25
26
|
import isEmpty from 'lodash/isEmpty';
|
|
26
27
|
import { markMessageRevoked, useMessageStore } from '../store/message';
|
|
27
28
|
import { syncPinnedMessagesOnEvent } from '../hooks/message/usePinMessage';
|
|
28
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
belongsToConversation,
|
|
31
|
+
conversationIdsOfMessage,
|
|
32
|
+
} from '../utils/message';
|
|
33
|
+
import { useInputStateStore } from '../store/inputState';
|
|
29
34
|
import { getConversationID } from '../utils/conversation';
|
|
30
35
|
import { clearSelfExitedGroup } from '../utils/groupExit';
|
|
31
36
|
import { DeviceEventEmitter } from 'react-native';
|
|
@@ -371,8 +376,34 @@ export const useChatListener = () => {
|
|
|
371
376
|
[updateOneMessage]
|
|
372
377
|
);
|
|
373
378
|
|
|
379
|
+
const onInputStatusChanged = useCallback(
|
|
380
|
+
(status: ConversationInputStatus) => {
|
|
381
|
+
// Never reflect the viewer's own composing back at them.
|
|
382
|
+
if (!status?.userID || status.userID === currentUserId) return;
|
|
383
|
+
|
|
384
|
+
// Stored for every conversation, not just the open one: the thread list
|
|
385
|
+
// shows typing for threads the user is not currently inside, and the
|
|
386
|
+
// store is keyed by conversation so each reader takes only its own.
|
|
387
|
+
useInputStateStore.getState().applyInputStatus(status);
|
|
388
|
+
},
|
|
389
|
+
[currentUserId]
|
|
390
|
+
);
|
|
391
|
+
|
|
374
392
|
const onRecvNewMessages = useCallback((messages: MessageItem[]) => {
|
|
375
393
|
// console.log('[Listener] OnRecvNewMessages', messages);
|
|
394
|
+
// A sender whose message just arrived has stopped composing. Runs over
|
|
395
|
+
// every message and above the guards below, because typing is tracked for
|
|
396
|
+
// all conversations rather than only the open one — and those guards bail
|
|
397
|
+
// outright while the user is sitting on the inbox.
|
|
398
|
+
const { removeTyper } = useInputStateStore.getState();
|
|
399
|
+
for (const message of messages) {
|
|
400
|
+
const senderId = message.sendID;
|
|
401
|
+
if (!senderId) continue;
|
|
402
|
+
conversationIdsOfMessage(message).forEach((id) =>
|
|
403
|
+
removeTyper(id, senderId)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
376
407
|
const conversationId =
|
|
377
408
|
useConversationStore.getState().currentConversationId;
|
|
378
409
|
const targetUserId = useConversationStore.getState().currentUserId;
|
|
@@ -415,6 +446,7 @@ export const useChatListener = () => {
|
|
|
415
446
|
OpenIMSDK.on(OpenIMEvent.OnNewRecvMessageRevoked, onMessageRevoked);
|
|
416
447
|
OpenIMSDK.on(OpenIMEvent.OnRecvMessagePinned, onMessagePinned);
|
|
417
448
|
OpenIMSDK.on(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
|
|
449
|
+
OpenIMSDK.on(OpenIMEvent.OnInputStatusChanged, onInputStatusChanged);
|
|
418
450
|
OpenIMSDK.on(
|
|
419
451
|
OpenIMEvent.OnRecvOfflineNewMessages,
|
|
420
452
|
onRecvOfflineNewMessages
|
|
@@ -444,6 +476,7 @@ export const useChatListener = () => {
|
|
|
444
476
|
// Message status
|
|
445
477
|
OpenIMSDK.off(OpenIMEvent.SendMessageProgress, onSendMessageProgress);
|
|
446
478
|
OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
|
|
479
|
+
OpenIMSDK.off(OpenIMEvent.OnInputStatusChanged, onInputStatusChanged);
|
|
447
480
|
OpenIMSDK.off(OpenIMEvent.OnNewRecvMessageRevoked, onMessageRevoked);
|
|
448
481
|
OpenIMSDK.off(OpenIMEvent.OnRecvMessagePinned, onMessagePinned);
|
|
449
482
|
OpenIMSDK.off(
|
|
@@ -461,6 +494,7 @@ export const useChatListener = () => {
|
|
|
461
494
|
onNewConversation,
|
|
462
495
|
onSendMessageProgress,
|
|
463
496
|
onRecvNewMessages,
|
|
497
|
+
onInputStatusChanged,
|
|
464
498
|
onRecvOfflineNewMessages,
|
|
465
499
|
OnGroupMemberInfoChanged,
|
|
466
500
|
onJoinedGroupDeleted,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import OpenIMSDK from '@droppii/openim-rn-client-sdk';
|
|
3
|
+
import { groupQueryKeys } from '../query-keys';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Display name of one group member.
|
|
7
|
+
*
|
|
8
|
+
* Fetches a single member rather than the whole roster: the only place this is
|
|
9
|
+
* needed is the typing sentence, which names at most one person — beyond that
|
|
10
|
+
* the copy collapses to "Nhiều người". Cached by react-query so a dot flickering
|
|
11
|
+
* on and off does not re-hit the SDK.
|
|
12
|
+
*/
|
|
13
|
+
export const useGroupMemberName = (
|
|
14
|
+
groupID?: string,
|
|
15
|
+
userID?: string
|
|
16
|
+
): string | undefined => {
|
|
17
|
+
const { data } = useQuery({
|
|
18
|
+
queryKey: groupQueryKeys.member(groupID, userID),
|
|
19
|
+
enabled: !!groupID && !!userID,
|
|
20
|
+
staleTime: 5 * 60_000,
|
|
21
|
+
queryFn: async () => {
|
|
22
|
+
const members = await OpenIMSDK.getSpecifiedGroupMembersInfo({
|
|
23
|
+
groupID: groupID!,
|
|
24
|
+
userIDList: [userID!],
|
|
25
|
+
});
|
|
26
|
+
return members?.[0]?.nickname ?? null;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return data ?? undefined;
|
|
31
|
+
};
|
package/src/hooks/query-keys.ts
CHANGED
|
@@ -83,4 +83,6 @@ export const groupQueryKeys = {
|
|
|
83
83
|
leave: () => [...groupQueryKeys.all, 'leave'] as const,
|
|
84
84
|
inviteInfo: (code: string) =>
|
|
85
85
|
[...groupQueryKeys.all, 'inviteInfo', code] as const,
|
|
86
|
+
member: (groupID?: string, userID?: string) =>
|
|
87
|
+
[...groupQueryKeys.all, 'member', groupID, userID] as const,
|
|
86
88
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useInputStateStore } from '../store';
|
|
3
|
+
import {
|
|
4
|
+
getActiveTypers,
|
|
5
|
+
getNextExpiryAt,
|
|
6
|
+
type InputStateRecord,
|
|
7
|
+
} from '../utils/inputState';
|
|
8
|
+
|
|
9
|
+
export interface InputStateResult {
|
|
10
|
+
/**
|
|
11
|
+
* Whether the indicator belongs on screen. Unlike the sentence this needs no
|
|
12
|
+
* name, so it can be answered here.
|
|
13
|
+
*/
|
|
14
|
+
isTyping: boolean;
|
|
15
|
+
/** Everyone still inside the window, oldest signal first. */
|
|
16
|
+
typers: readonly InputStateRecord[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Typing state for one conversation.
|
|
21
|
+
*
|
|
22
|
+
* Expiry is read from each record's timestamp rather than tracked with a timer
|
|
23
|
+
* per user, and the hook schedules a single wake-up at the moment the next
|
|
24
|
+
* typer falls out of the window. So with A typing at t=0 and B at t=2s the
|
|
25
|
+
* sentence moves from A, to both, to B alone at t=3s, and disappears at t=5s —
|
|
26
|
+
* without polling between those points.
|
|
27
|
+
*/
|
|
28
|
+
export const useInputState = (conversationID?: string): InputStateResult => {
|
|
29
|
+
const state = useInputStateStore((store) =>
|
|
30
|
+
conversationID ? store.byConversation[conversationID] : undefined
|
|
31
|
+
);
|
|
32
|
+
// Bumped by the expiry timer below. Records go stale on a clock rather than
|
|
33
|
+
// on a store write, so something has to ask the question again.
|
|
34
|
+
const [tick, setTick] = useState(0);
|
|
35
|
+
|
|
36
|
+
const typers = useMemo(
|
|
37
|
+
// `tick` is not read here — it is a dep so that the expiry timer below can
|
|
38
|
+
// force a recount. Records fall out of the window on a clock, so `state`
|
|
39
|
+
// alone would leave an expired typer on screen until the next store write.
|
|
40
|
+
() => getActiveTypers(state, Date.now()),
|
|
41
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
42
|
+
[state, tick]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const expiresAt = getNextExpiryAt(state, Date.now());
|
|
47
|
+
if (expiresAt === null) return;
|
|
48
|
+
|
|
49
|
+
const timer = setTimeout(
|
|
50
|
+
() => setTick((value) => value + 1),
|
|
51
|
+
Math.max(0, expiresAt - Date.now())
|
|
52
|
+
);
|
|
53
|
+
return () => clearTimeout(timer);
|
|
54
|
+
}, [state, typers]);
|
|
55
|
+
|
|
56
|
+
return { isTyping: typers.length > 0, typers };
|
|
57
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import OpenIMSDK from '@droppii/openim-rn-client-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Broadcasts this device's composing state for a conversation.
|
|
6
|
+
*
|
|
7
|
+
* The SDK throttles the outgoing frames itself, so `notifyTyping` is safe to
|
|
8
|
+
* call on every keystroke — no debounce here would only duplicate that. What
|
|
9
|
+
* this hook does own is the start/stop rule: an empty draft counts as stopping,
|
|
10
|
+
* not as typing, and leaving the screen has to stop too or the peer is left
|
|
11
|
+
* watching an indicator for a composer nobody is in front of.
|
|
12
|
+
*/
|
|
13
|
+
export const useInputStateBroadcast = (conversationID?: string) => {
|
|
14
|
+
// Mirrors what we last told the SDK, so a cleared draft or a blur does not
|
|
15
|
+
// fire a stop frame for a conversation we never announced typing in.
|
|
16
|
+
const isTypingRef = useRef(false);
|
|
17
|
+
|
|
18
|
+
const send = useCallback(
|
|
19
|
+
(focus: boolean) => {
|
|
20
|
+
if (!conversationID) return;
|
|
21
|
+
// Only a repeated *stop* is redundant. A repeated start is the heartbeat
|
|
22
|
+
// that keeps the peer's TTL window open — suppressing it would let the
|
|
23
|
+
// indicator expire while the person is still typing.
|
|
24
|
+
if (!focus && !isTypingRef.current) return;
|
|
25
|
+
|
|
26
|
+
isTypingRef.current = focus;
|
|
27
|
+
OpenIMSDK.changeInputStates({ conversationID, focus }).catch(() => {
|
|
28
|
+
// Typing state is cosmetic — a failed frame must not surface to the
|
|
29
|
+
// person typing, and the peer's own 3s window cleans up after it.
|
|
30
|
+
isTypingRef.current = !focus;
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
[conversationID]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/** Any draft change: text present means typing, empty means stopped. */
|
|
37
|
+
const notifyTyping = useCallback(
|
|
38
|
+
(text?: string) => send(!!text && text.trim().length > 0),
|
|
39
|
+
[send]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Explicit stop — message sent, input blurred, screen left. */
|
|
43
|
+
const notifyStoppedTyping = useCallback(() => send(false), [send]);
|
|
44
|
+
|
|
45
|
+
// Switching conversations, or unmounting, stops the previous one.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
return () => {
|
|
48
|
+
notifyStoppedTyping();
|
|
49
|
+
};
|
|
50
|
+
}, [notifyStoppedTyping]);
|
|
51
|
+
|
|
52
|
+
return { notifyTyping, notifyStoppedTyping };
|
|
53
|
+
};
|
|
@@ -59,6 +59,7 @@ import { DFlowState } from '../../types/chat';
|
|
|
59
59
|
import { useAttachmentMapping } from './hooks/useAttachmentMapping';
|
|
60
60
|
import type { AttachmentType } from '../../types/attachment';
|
|
61
61
|
import { useChatCompose } from '../../hooks/useChatCompose';
|
|
62
|
+
import { useInputStateBroadcast } from '../../hooks/useInputStateBroadcast';
|
|
62
63
|
import { ChatCurrentQuotedMessage } from './ChatCurrentQuotedMessage';
|
|
63
64
|
import { useSendMessage } from '../../hooks/message/useSendMessage';
|
|
64
65
|
import { openBotMenuSheet } from './BotMenuSheet';
|
|
@@ -111,6 +112,9 @@ export const ChatComposer = memo(
|
|
|
111
112
|
const { mutateAsync: closeSupportSession } = useCloseSupportSession();
|
|
112
113
|
const { sendMessage } = useSendMessage();
|
|
113
114
|
const compose = useChatCompose();
|
|
115
|
+
const { notifyTyping, notifyStoppedTyping } = useInputStateBroadcast(
|
|
116
|
+
conversation?.conversationID
|
|
117
|
+
);
|
|
114
118
|
const { allowed: isSendMessageAllowed, reason: sendDeniedReason } =
|
|
115
119
|
useCanSendMessage();
|
|
116
120
|
|
|
@@ -226,6 +230,9 @@ export const ChatComposer = memo(
|
|
|
226
230
|
// "ghost" mention anchored past the end of an empty string). See
|
|
227
231
|
// useMentionComposer.clear()'s doc comment.
|
|
228
232
|
mentionComposer.clear();
|
|
233
|
+
// Stop before awaiting the send: the peer should lose the indicator as
|
|
234
|
+
// the message goes out, not once it round-trips.
|
|
235
|
+
notifyStoppedTyping();
|
|
229
236
|
try {
|
|
230
237
|
await compose.send(mentionsToSend);
|
|
231
238
|
} catch (error) {
|
|
@@ -248,6 +255,7 @@ export const ChatComposer = memo(
|
|
|
248
255
|
sendDeniedReason,
|
|
249
256
|
mentionComposer.mentions,
|
|
250
257
|
mentionComposer.clear,
|
|
258
|
+
notifyStoppedTyping,
|
|
251
259
|
inputRef,
|
|
252
260
|
]);
|
|
253
261
|
|
|
@@ -324,12 +332,19 @@ export const ChatComposer = memo(
|
|
|
324
332
|
|
|
325
333
|
const handleInputBlur = useCallback(() => {
|
|
326
334
|
setIsInputFocused(false);
|
|
327
|
-
|
|
335
|
+
notifyStoppedTyping();
|
|
336
|
+
}, [setIsInputFocused, notifyStoppedTyping]);
|
|
328
337
|
|
|
329
338
|
// Routes through mentionComposer instead of compose.setText directly —
|
|
330
339
|
// it owns the atomic-delete/reconcile pipeline and calls compose.setText
|
|
331
340
|
// itself once the corrected text is known (see useMentionComposer).
|
|
332
|
-
const handleChangeText =
|
|
341
|
+
const handleChangeText = useCallback(
|
|
342
|
+
(text?: string) => {
|
|
343
|
+
notifyTyping(text);
|
|
344
|
+
mentionComposer.handleChangeText(text);
|
|
345
|
+
},
|
|
346
|
+
[notifyTyping, mentionComposer]
|
|
347
|
+
);
|
|
333
348
|
|
|
334
349
|
const handleInputContentSizeChange = useCallback(
|
|
335
350
|
(event: { nativeEvent: { contentSize: { height: number } } }) => {
|
|
@@ -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
|
|
|
@@ -68,6 +68,7 @@ import { resolveImageDisplayDims } from '../../components/messages/imageMessage'
|
|
|
68
68
|
import ChatImageDimens, {
|
|
69
69
|
CHAT_IMAGE_VIEW_SOURCE,
|
|
70
70
|
} from '../../utils/chatImageDimens';
|
|
71
|
+
import { ChatTypingIndicator } from './ChatTypingIndicator';
|
|
71
72
|
|
|
72
73
|
// Cluster spacing: a wide gap after the message that closes a cluster, a tight
|
|
73
74
|
// one between messages inside it. These override the flat margin on the row
|
|
@@ -1020,6 +1021,7 @@ export const ChatListLegend = memo(
|
|
|
1020
1021
|
{showBadge && <KContainer.View style={styles.badge} />}
|
|
1021
1022
|
</KContainer.Touchable>
|
|
1022
1023
|
</Animated.View>
|
|
1024
|
+
<ChatTypingIndicator />
|
|
1023
1025
|
</KContainer.View>
|
|
1024
1026
|
);
|
|
1025
1027
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import { KColors, KContainer, KLabel, KSpacingValue } from '@droppii/libs';
|
|
4
|
+
import { PeerType } from '@droppii/openim-rn-client-sdk';
|
|
5
|
+
import { TypingDots } from '../../components/TypingDots';
|
|
6
|
+
import { useChatDetailContext } from '../../context/ChatDetailContext';
|
|
7
|
+
import { useGroupMemberName } from '../../hooks/groups/useGroupMemberName';
|
|
8
|
+
import { useInputState } from '../../hooks/useInputState';
|
|
9
|
+
import { resolveTypingText } from '../../utils/inputState';
|
|
10
|
+
import { trans } from '../../translation';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "<name> đang nhập..." above the composer, with the bouncing dots.
|
|
14
|
+
*
|
|
15
|
+
* Renders nothing while nobody is typing — an empty row would reserve height
|
|
16
|
+
* and nudge the message list every time somebody started or stopped.
|
|
17
|
+
*/
|
|
18
|
+
export const ChatTypingIndicator = memo(() => {
|
|
19
|
+
const { conversation } = useChatDetailContext();
|
|
20
|
+
const isGroup = conversation?.peerType === PeerType.Group;
|
|
21
|
+
|
|
22
|
+
const { isTyping, typers } = useInputState(conversation?.conversationID);
|
|
23
|
+
|
|
24
|
+
// Only the first typer is ever named — past one person the copy collapses to
|
|
25
|
+
// "Nhiều người", so a single lookup covers every case.
|
|
26
|
+
const firstTyperId = typers[0]?.userID;
|
|
27
|
+
const memberName = useGroupMemberName(
|
|
28
|
+
isGroup ? conversation?.groupID : undefined,
|
|
29
|
+
isGroup ? firstTyperId : undefined
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// A 1:1 or bot thread is named after its only other participant, so the
|
|
33
|
+
// conversation's own name is the answer and no lookup is needed.
|
|
34
|
+
// A key plus its values, not a finished string: the name renders bold while
|
|
35
|
+
// the rest does not, so RichText needs the pieces apart.
|
|
36
|
+
const copy = resolveTypingText(
|
|
37
|
+
typers.length,
|
|
38
|
+
isGroup ? memberName : conversation?.showName
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!isTyping) return null;
|
|
42
|
+
|
|
43
|
+
// Dots go up the moment somebody types; the sentence follows once the name
|
|
44
|
+
// resolves. Waiting for the name would hold the whole indicator back on a
|
|
45
|
+
// network round-trip the dots do not depend on.
|
|
46
|
+
return (
|
|
47
|
+
<KContainer.View row style={styles.row}>
|
|
48
|
+
{!!copy && (
|
|
49
|
+
<KLabel.RichText
|
|
50
|
+
typo="TextXsNormal"
|
|
51
|
+
color={KColors.gray.light}
|
|
52
|
+
numberOfLines={1}
|
|
53
|
+
richTextOptions={{
|
|
54
|
+
i18nKey: trans(copy.key),
|
|
55
|
+
values: copy.values,
|
|
56
|
+
components: {
|
|
57
|
+
bold: (
|
|
58
|
+
<KLabel.Text typo="TextXsBold" color={KColors.gray.light} />
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
<TypingDots
|
|
65
|
+
fill={KColors.gray.light}
|
|
66
|
+
width={13}
|
|
67
|
+
height={5.91}
|
|
68
|
+
style={styles.dot}
|
|
69
|
+
/>
|
|
70
|
+
</KContainer.View>
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
ChatTypingIndicator.displayName = 'ChatTypingIndicator';
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
row: {
|
|
78
|
+
paddingHorizontal: KSpacingValue['0.5rem'],
|
|
79
|
+
paddingVertical: 2,
|
|
80
|
+
gap: 2,
|
|
81
|
+
alignItems: 'flex-end',
|
|
82
|
+
position: 'absolute',
|
|
83
|
+
bottom: 0,
|
|
84
|
+
left: 0,
|
|
85
|
+
},
|
|
86
|
+
dot: {
|
|
87
|
+
marginBottom: 3,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
@@ -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}
|