@droppii-org/chat-mobile 0.2.25 → 0.2.26

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 (88) hide show
  1. package/lib/module/components/flows/inputButtons/index.js +3 -6
  2. package/lib/module/components/flows/inputButtons/index.js.map +1 -1
  3. package/lib/module/components/messages/fileMessage/index.js +1 -1
  4. package/lib/module/components/messages/fileMessage/index.js.map +1 -1
  5. package/lib/module/components/messages/imageMessage/index.js +1 -1
  6. package/lib/module/components/messages/imageMessage/index.js.map +1 -1
  7. package/lib/module/components/messages/linkMessage/index.js +7 -7
  8. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  9. package/lib/module/components/messages/mergedMessage/index.js +5 -8
  10. package/lib/module/components/messages/mergedMessage/index.js.map +1 -1
  11. package/lib/module/components/messages/styles.js +12 -0
  12. package/lib/module/components/messages/styles.js.map +1 -1
  13. package/lib/module/components/messages/textMessage/index.js +10 -7
  14. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  15. package/lib/module/components/messages/videoMessage/index.js +7 -11
  16. package/lib/module/components/messages/videoMessage/index.js.map +1 -1
  17. package/lib/module/screens/chat-detail/ChatDetail.js +11 -1
  18. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  19. package/lib/module/screens/chat-detail/ChatListLegend.js +21 -14
  20. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  21. package/lib/module/screens/chat-detail/constants.js +3 -2
  22. package/lib/module/screens/chat-detail/constants.js.map +1 -1
  23. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +36 -8
  24. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  25. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxMenu.js +98 -0
  26. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxMenu.js.map +1 -0
  27. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +110 -0
  28. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -0
  29. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +64 -0
  30. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -0
  31. package/lib/module/screens/chat-detail/messageToolbox/messageToolbox.utils.js +37 -0
  32. package/lib/module/screens/chat-detail/messageToolbox/messageToolbox.utils.js.map +1 -0
  33. package/lib/module/screens/chat-detail/messageToolbox/types.js +4 -0
  34. package/lib/module/screens/chat-detail/messageToolbox/types.js.map +1 -0
  35. package/lib/module/translation/resources/i18n.js +5 -0
  36. package/lib/module/translation/resources/i18n.js.map +1 -1
  37. package/lib/module/utils/ui.js +19 -0
  38. package/lib/module/utils/ui.js.map +1 -0
  39. package/lib/typescript/src/components/flows/inputButtons/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/messages/mergedMessage/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/messages/styles.d.ts +15 -3
  43. package/lib/typescript/src/components/messages/styles.d.ts.map +1 -1
  44. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/messages/videoMessage/index.d.ts.map +1 -1
  46. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  47. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +1 -1
  48. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  49. package/lib/typescript/src/screens/chat-detail/constants.d.ts +2 -2
  50. package/lib/typescript/src/screens/chat-detail/constants.d.ts.map +1 -1
  51. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +2 -1
  52. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  53. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxMenu.d.ts +11 -0
  54. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxMenu.d.ts.map +1 -0
  55. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts +10 -0
  56. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -0
  57. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +11 -0
  58. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -0
  59. package/lib/typescript/src/screens/chat-detail/messageToolbox/messageToolbox.utils.d.ts +18 -0
  60. package/lib/typescript/src/screens/chat-detail/messageToolbox/messageToolbox.utils.d.ts.map +1 -0
  61. package/lib/typescript/src/screens/chat-detail/messageToolbox/types.d.ts +12 -0
  62. package/lib/typescript/src/screens/chat-detail/messageToolbox/types.d.ts.map +1 -0
  63. package/lib/typescript/src/screens/chat-detail/types.d.ts +2 -0
  64. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  65. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  66. package/lib/typescript/src/utils/ui.d.ts +2 -0
  67. package/lib/typescript/src/utils/ui.d.ts.map +1 -0
  68. package/package.json +3 -1
  69. package/src/components/flows/inputButtons/index.tsx +3 -5
  70. package/src/components/messages/fileMessage/index.tsx +0 -1
  71. package/src/components/messages/imageMessage/index.tsx +1 -1
  72. package/src/components/messages/linkMessage/index.tsx +4 -3
  73. package/src/components/messages/mergedMessage/index.tsx +13 -17
  74. package/src/components/messages/styles.ts +12 -0
  75. package/src/components/messages/textMessage/index.tsx +8 -3
  76. package/src/components/messages/videoMessage/index.tsx +8 -10
  77. package/src/screens/chat-detail/ChatDetail.tsx +17 -0
  78. package/src/screens/chat-detail/ChatListLegend.tsx +30 -16
  79. package/src/screens/chat-detail/constants.ts +3 -2
  80. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +49 -11
  81. package/src/screens/chat-detail/messageToolbox/MessageToolboxMenu.tsx +114 -0
  82. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +130 -0
  83. package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +99 -0
  84. package/src/screens/chat-detail/messageToolbox/messageToolbox.utils.ts +56 -0
  85. package/src/screens/chat-detail/messageToolbox/types.ts +13 -0
  86. package/src/screens/chat-detail/types.ts +2 -0
  87. package/src/translation/resources/i18n.ts +5 -0
  88. package/src/utils/ui.ts +23 -0
@@ -1,5 +1,5 @@
1
1
  import { memo, useCallback, useMemo, useEffect, useRef, useState } from 'react';
2
- import { StyleSheet } from 'react-native';
2
+ import { StyleSheet, type View } from 'react-native';
3
3
  import Animated, {
4
4
  useSharedValue,
5
5
  useAnimatedStyle,
@@ -19,6 +19,7 @@ import { InputButtonFlow } from '../../components/flows/inputButtons';
19
19
  import { useResetFlow, useRunFlow } from '../../hooks/flows/useFlow';
20
20
  import type { IRunConversationFlowPayload } from '../../types/flows';
21
21
  import { useSendMessage } from '../../hooks/message/useSendMessage';
22
+ import { messageStyles } from './legend/messageTypes';
22
23
 
23
24
  export const ChatListLegend = memo(
24
25
  ({
@@ -33,6 +34,7 @@ export const ChatListLegend = memo(
33
34
  hasMoreNewer,
34
35
  onMediaPress,
35
36
  onPressUrl,
37
+ onLongPress,
36
38
  }: ChatListProps) => {
37
39
  // Precompute all message data in single pass (O(n))
38
40
  const messageDataMap = useMemo(
@@ -60,6 +62,7 @@ export const ChatListLegend = memo(
60
62
  const itemHeightsRef = useRef<Map<string, number>>(new Map());
61
63
  const avgItemHeightRef = useRef(120);
62
64
  const heightDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
65
+ const rowRefsMap = useRef<Map<string, View | null>>(new Map());
63
66
  const [showBadge, setShowBadge] = useState(false);
64
67
 
65
68
  const handleItemLayout = useCallback(
@@ -86,6 +89,16 @@ export const ChatListLegend = memo(
86
89
  []
87
90
  );
88
91
 
92
+ const handleMessageLongPress = useCallback(
93
+ (messageId: string, message: DMessageItem) => {
94
+ const node = rowRefsMap.current.get(messageId);
95
+ node?.measureInWindow((x, y, width, height) => {
96
+ onLongPress?.(message, { x, y, width, height });
97
+ });
98
+ },
99
+ [onLongPress]
100
+ );
101
+
89
102
  const onRunFlow = useCallback(
90
103
  (payload: IRunConversationFlowPayload) => {
91
104
  runFlow(payload);
@@ -130,10 +143,15 @@ export const ChatListLegend = memo(
130
143
  <>
131
144
  {dayStart && <LegendChatDay createdAt={createdAt} />}
132
145
  <KContainer.View
133
- style={[
134
- styles.messageRow,
135
- isOutgoing ? styles.messageRowRight : styles.messageRowLeft,
136
- ]}
146
+ ref={(node: View | null) => {
147
+ if (node) rowRefsMap.current.set(messageId, node);
148
+ else rowRefsMap.current.delete(messageId);
149
+ }}
150
+ style={
151
+ isOutgoing
152
+ ? messageStyles.messageRowRight
153
+ : messageStyles.messageRowLeft
154
+ }
137
155
  onLayout={(e) =>
138
156
  handleItemLayout(messageId, e.nativeEvent.layout.height)
139
157
  }
@@ -145,6 +163,11 @@ export const ChatListLegend = memo(
145
163
  onMediaPress={onMediaPress}
146
164
  onPressUrl={onPressUrl}
147
165
  onRetry={retryMessage}
166
+ onLongPress={
167
+ onLongPress
168
+ ? () => handleMessageLongPress(messageId, message)
169
+ : undefined
170
+ }
148
171
  customMessageItemFactory={customMessageItemFactory}
149
172
  />
150
173
  {!!flowInputButtonsNode && (
@@ -176,6 +199,8 @@ export const ChatListLegend = memo(
176
199
  isBotConversation,
177
200
  onMediaPress,
178
201
  onPressUrl,
202
+ onLongPress,
203
+ handleMessageLongPress,
179
204
  customMessageItemFactory,
180
205
  onResetFlow,
181
206
  onRunFlow,
@@ -399,17 +424,6 @@ const styles = StyleSheet.create({
399
424
  container: {
400
425
  position: 'relative',
401
426
  },
402
- messageRow: {
403
- width: '100%',
404
- paddingHorizontal: KSpacingValue['0.75rem'],
405
- marginBottom: KSpacingValue['0.25rem'],
406
- },
407
- messageRowLeft: {
408
- alignItems: 'flex-start',
409
- },
410
- messageRowRight: {
411
- alignItems: 'flex-end',
412
- },
413
427
  content: {
414
428
  paddingVertical: KSpacingValue['0.75rem'],
415
429
  flexGrow: 1,
@@ -1,9 +1,10 @@
1
1
  import { KColors } from '@droppii/libs';
2
2
  import type { DChatQuickAction, DChatAttachmentAction } from './types';
3
+ import { opacityToSolid } from '../../utils/ui';
3
4
 
4
5
  export const CHAT_BUBBLE_COLORS = {
5
- received: 'rgba(131, 137, 157, 0.1)',
6
- sent: 'rgba(0, 81, 255, 0.1)',
6
+ received: opacityToSolid('#83899d1a', KColors.white, 0.1),
7
+ sent: opacityToSolid('#0051ff1a', KColors.white, 0.1),
7
8
  text: KColors.gray.dark,
8
9
  timestamp: KColors.gray.light,
9
10
  dayLabel: KColors.gray.light,
@@ -1,8 +1,10 @@
1
- import { memo } from 'react';
2
- import type { ComponentType, ReactNode } from 'react';
3
1
  import { KContainer } from '@droppii/libs';
2
+ import { memo, useMemo } from 'react';
3
+ import type { ComponentType, PropsWithChildren, ReactNode } from 'react';
4
4
  import { MessageType } from '@droppii/openim-rn-client-sdk';
5
5
  import type { MessageItem } from '@droppii/openim-rn-client-sdk';
6
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
7
+ import { runOnJS } from 'react-native-reanimated';
6
8
  import type { DMessageItem } from '../../../types/chat';
7
9
  import type { MediaItem } from '../../../hooks/useMediaViewer';
8
10
  import { TextMessage } from '../../../components/messages/textMessage';
@@ -13,9 +15,11 @@ import { LinkMessage } from '../../../components/messages/linkMessage';
13
15
  import { MergedMessage } from '../../../components/messages/mergedMessage';
14
16
  import { MessageStatusIndicator } from '../../../components/messages/MessageStatusIndicator';
15
17
  import type { BaseLegendMessageProps } from '../../../components/messages/types';
18
+ import { messageStyles } from './messageTypes';
16
19
 
17
20
  type MessageProps = BaseLegendMessageProps & {
18
21
  onPressUrl?: (url: string) => void;
22
+ onLongPress?: () => void;
19
23
  };
20
24
 
21
25
  interface LegendChatMessageProps {
@@ -25,6 +29,7 @@ interface LegendChatMessageProps {
25
29
  onMediaPress?: (items: MediaItem[], index?: number) => void;
26
30
  onPressUrl?: (url: string) => void;
27
31
  onRetry?: (message: MessageItem) => void;
32
+ onLongPress?: () => void;
28
33
  customMessageItemFactory?: (m: DMessageItem) => ReactNode;
29
34
  }
30
35
 
@@ -58,11 +63,17 @@ export const LegendChatMessage = memo(
58
63
  onMediaPress,
59
64
  onPressUrl,
60
65
  onRetry,
66
+ onLongPress,
61
67
  customMessageItemFactory,
62
68
  }: LegendChatMessageProps) => {
63
69
  const customNode = customMessageItemFactory?.(message);
70
+
64
71
  if (customNode) {
65
- return customNode;
72
+ return (
73
+ <LongPressWrapper onLongPress={onLongPress}>
74
+ {customNode}
75
+ </LongPressWrapper>
76
+ );
66
77
  }
67
78
 
68
79
  const MessageComponent: ComponentType<MessageProps> =
@@ -74,22 +85,49 @@ export const LegendChatMessage = memo(
74
85
  onPressUrl && message?.contentType === MessageType.UrlTextMessage;
75
86
 
76
87
  return (
77
- <KContainer.View row alignItems>
88
+ <KContainer.View style={messageStyles.wrapper} row alignItems>
78
89
  <MessageStatusIndicator
79
90
  message={message}
80
91
  isOutgoing={isOutgoing}
81
92
  onRetry={onRetry}
82
93
  />
83
- <MessageComponent
84
- message={message}
85
- isOutgoing={isOutgoing}
86
- createdAtTime={createdAtTime}
87
- onMediaPress={hasMediaPress ? onMediaPress : undefined}
88
- onPressUrl={hasUrlPress ? onPressUrl : undefined}
89
- />
94
+ <LongPressWrapper onLongPress={onLongPress}>
95
+ <MessageComponent
96
+ message={message}
97
+ isOutgoing={isOutgoing}
98
+ createdAtTime={createdAtTime}
99
+ onMediaPress={hasMediaPress ? onMediaPress : undefined}
100
+ onPressUrl={hasUrlPress ? onPressUrl : undefined}
101
+ />
102
+ </LongPressWrapper>
90
103
  </KContainer.View>
91
104
  );
92
105
  }
93
106
  );
94
107
 
95
108
  LegendChatMessage.displayName = 'LegendChatMessage';
109
+
110
+ const LongPressWrapper = ({
111
+ children,
112
+ onLongPress,
113
+ }: PropsWithChildren<{ onLongPress?: () => void }>) => {
114
+ const longPressGesture = useMemo(
115
+ () =>
116
+ onLongPress
117
+ ? Gesture.LongPress().onStart(() => {
118
+ runOnJS(onLongPress)();
119
+ })
120
+ : null,
121
+ [onLongPress]
122
+ );
123
+
124
+ if (!longPressGesture) {
125
+ return children;
126
+ }
127
+
128
+ return (
129
+ <GestureDetector gesture={longPressGesture}>
130
+ <KContainer.View>{children}</KContainer.View>
131
+ </GestureDetector>
132
+ );
133
+ };
@@ -0,0 +1,114 @@
1
+ import { memo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import {
4
+ KContainer,
5
+ KImage,
6
+ KLabel,
7
+ KColors,
8
+ KSpacingValue,
9
+ } from '@droppii/libs';
10
+ import { trans } from '../../../translation';
11
+
12
+ interface MessageToolboxMenuProps {
13
+ showCopyMessage?: boolean;
14
+ showCopyMessageLink?: boolean;
15
+ showRetryMessage?: boolean;
16
+ onCopyMessage: () => void;
17
+ onCopyMessageLink: () => void;
18
+ onRetryMessage: () => void;
19
+ }
20
+
21
+ export const MessageToolboxMenu = memo(
22
+ ({
23
+ showCopyMessage,
24
+ showCopyMessageLink,
25
+ showRetryMessage,
26
+ onCopyMessage,
27
+ onCopyMessageLink,
28
+ onRetryMessage,
29
+ }: MessageToolboxMenuProps) => {
30
+ if (!showCopyMessage && !showCopyMessageLink && !showRetryMessage) {
31
+ return null;
32
+ }
33
+
34
+ return (
35
+ <KContainer.View style={styles.card}>
36
+ <KContainer.VisibleView visible={!!showCopyMessage}>
37
+ <KContainer.Touchable style={styles.row} onPress={onCopyMessage}>
38
+ <KImage.VectorIcons
39
+ name="copy-o"
40
+ provider="DroppiiNew"
41
+ size={24}
42
+ color={KColors.gray.dark}
43
+ />
44
+ <KLabel.Text
45
+ typo="TextMdMedium"
46
+ color={KColors.gray.dark}
47
+ marginL="0.75rem"
48
+ >
49
+ {trans('chat:copy_message')}
50
+ </KLabel.Text>
51
+ </KContainer.Touchable>
52
+ </KContainer.VisibleView>
53
+
54
+ <KContainer.VisibleView visible={!!showCopyMessageLink}>
55
+ <KContainer.Touchable style={styles.row} onPress={onCopyMessageLink}>
56
+ <KImage.VectorIcons
57
+ name="link-o"
58
+ provider="DroppiiNew"
59
+ size={24}
60
+ color={KColors.gray.dark}
61
+ />
62
+ <KLabel.Text
63
+ typo="TextMdMedium"
64
+ color={KColors.gray.dark}
65
+ marginL="0.75rem"
66
+ >
67
+ {trans('chat:copy_message_link')}
68
+ </KLabel.Text>
69
+ </KContainer.Touchable>
70
+ </KContainer.VisibleView>
71
+
72
+ <KContainer.VisibleView visible={!!showRetryMessage}>
73
+ <KContainer.Touchable style={styles.row} onPress={onRetryMessage}>
74
+ <KImage.VectorIcons
75
+ name="arrow-reload-o"
76
+ provider="DroppiiNew"
77
+ size={24}
78
+ color={KColors.danger.normal}
79
+ />
80
+ <KLabel.Text
81
+ typo="TextMdMedium"
82
+ color={KColors.danger.normal}
83
+ marginL="0.75rem"
84
+ >
85
+ {trans('chat:retry_message')}
86
+ </KLabel.Text>
87
+ </KContainer.Touchable>
88
+ </KContainer.VisibleView>
89
+ </KContainer.View>
90
+ );
91
+ }
92
+ );
93
+
94
+ MessageToolboxMenu.displayName = 'MessageToolboxMenu';
95
+
96
+ const styles = StyleSheet.create({
97
+ card: {
98
+ minWidth: 220,
99
+ paddingVertical: KSpacingValue['0.5rem'],
100
+ borderRadius: KSpacingValue['1rem'],
101
+ backgroundColor: KColors.white,
102
+ shadowColor: KColors.black,
103
+ shadowOffset: { width: 0, height: 0 },
104
+ shadowOpacity: 0.1,
105
+ shadowRadius: 24,
106
+ elevation: 8,
107
+ },
108
+ row: {
109
+ flexDirection: 'row',
110
+ alignItems: 'center',
111
+ paddingHorizontal: KSpacingValue['1rem'],
112
+ paddingVertical: KSpacingValue['0.5rem'],
113
+ },
114
+ });
@@ -0,0 +1,130 @@
1
+ import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
2
+ import { Modal, Pressable, StyleSheet } from 'react-native';
3
+ import Clipboard from '@react-native-clipboard/clipboard';
4
+ import { UIUtils } from '../../../utils/UIUtils';
5
+ import { trans } from '../../../translation';
6
+ import { LegendChatMessage } from '../legend/LegendChatMessage';
7
+ import { MessageToolboxMenu } from './MessageToolboxMenu';
8
+ import {
9
+ getCopyableMessageText,
10
+ useMessageToolboxItems,
11
+ } from './messageToolbox.utils';
12
+ import type { DMessageItem } from '../../../types/chat';
13
+ import type { MeasuredRect, MessageToolboxRef } from './types';
14
+ import { MessageToolboxWrapper } from './MessageToolboxWrapper';
15
+ import type { ReactNode } from 'react';
16
+ import { useSendMessage } from '../../../hooks/message/useSendMessage';
17
+
18
+ interface MessageToolboxOverlayProps {
19
+ currentUserId?: string;
20
+ customMessageItemFactory?: (item: DMessageItem) => ReactNode;
21
+ }
22
+
23
+ export const MessageToolboxOverlay = forwardRef<
24
+ MessageToolboxRef,
25
+ MessageToolboxOverlayProps
26
+ >(({ currentUserId = '', customMessageItemFactory }, ref) => {
27
+ const [visible, setVisible] = useState(false);
28
+ const [message, setMessage] = useState<DMessageItem | undefined>(undefined);
29
+ const [rect, setRect] = useState<MeasuredRect | null>(null);
30
+ const toolboxItems = useMessageToolboxItems(message);
31
+ const { retryMessage } = useSendMessage();
32
+ console.log(message);
33
+
34
+ const hide = useCallback(() => {
35
+ setVisible(false);
36
+ setMessage(undefined);
37
+ setRect(null);
38
+ }, []);
39
+
40
+ const show = useCallback((msg: DMessageItem, msgRect: MeasuredRect) => {
41
+ setMessage(msg);
42
+ setRect(msgRect);
43
+ setVisible(true);
44
+ }, []);
45
+
46
+ useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
47
+
48
+ const handleCopyMessage = useCallback(() => {
49
+ if (!message) return;
50
+ const text = getCopyableMessageText(message);
51
+ if (text) {
52
+ Clipboard.setString(text);
53
+ UIUtils.toast.open({
54
+ title: trans('chat:copy_message_success'),
55
+ theme: 'success',
56
+ });
57
+ }
58
+ hide();
59
+ }, [message, hide]);
60
+
61
+ const handleCopyMessageLink = useCallback(() => {
62
+ if (!message) return;
63
+ // UI-only for now: real shareable deeplink format (BR-001) is not decided yet.
64
+ const placeholderLink = `${message.recvID || message.groupID}:${message.clientMsgID ?? message.serverMsgID ?? ''}`;
65
+ Clipboard.setString(placeholderLink);
66
+ UIUtils.toast.open({
67
+ title: trans('chat:copy_message_link_success'),
68
+ theme: 'success',
69
+ });
70
+ hide();
71
+ }, [message, hide]);
72
+
73
+ const handleRetryMessage = useCallback(() => {
74
+ if (!message) return;
75
+ retryMessage(message);
76
+ hide();
77
+ }, [message, hide, retryMessage]);
78
+
79
+ if (!visible || !message || !rect) {
80
+ return null;
81
+ }
82
+
83
+ const isOutgoing = message.sendID === currentUserId;
84
+
85
+ return (
86
+ <Modal
87
+ visible={visible}
88
+ transparent
89
+ statusBarTranslucent
90
+ animationType="fade"
91
+ onRequestClose={hide}
92
+ >
93
+ <Pressable style={styles.backdrop} onPress={hide} />
94
+
95
+ <MessageToolboxWrapper
96
+ rect={rect}
97
+ isOutgoing={isOutgoing}
98
+ message={
99
+ <LegendChatMessage
100
+ message={message}
101
+ isOutgoing={isOutgoing}
102
+ createdAtTime={message.sendTime || message.createTime}
103
+ customMessageItemFactory={customMessageItemFactory}
104
+ />
105
+ }
106
+ toolbox={
107
+ <MessageToolboxMenu
108
+ {...toolboxItems}
109
+ onCopyMessage={handleCopyMessage}
110
+ onCopyMessageLink={handleCopyMessageLink}
111
+ onRetryMessage={handleRetryMessage}
112
+ />
113
+ }
114
+ />
115
+ </Modal>
116
+ );
117
+ });
118
+
119
+ MessageToolboxOverlay.displayName = 'MessageToolboxOverlay';
120
+
121
+ const styles = StyleSheet.create({
122
+ backdrop: {
123
+ position: 'absolute',
124
+ top: 0,
125
+ left: 0,
126
+ right: 0,
127
+ bottom: 0,
128
+ backgroundColor: 'rgba(0,0,0,0.75)',
129
+ },
130
+ });
@@ -0,0 +1,99 @@
1
+ import { useCallback, memo } from 'react';
2
+ import {
3
+ View,
4
+ Platform,
5
+ useWindowDimensions,
6
+ type LayoutChangeEvent,
7
+ } from 'react-native';
8
+ import {
9
+ useAnimatedStyle,
10
+ useDerivedValue,
11
+ useSharedValue,
12
+ withTiming,
13
+ } from 'react-native-reanimated';
14
+ import type { MeasuredRect } from './types';
15
+ import { messageStyles } from '../legend/messageTypes';
16
+ import Animated from 'react-native-reanimated';
17
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
18
+
19
+ const MENU_GAP = 8;
20
+
21
+ type MessageToolboxWrapperProps = {
22
+ isOutgoing?: boolean;
23
+ rect: MeasuredRect;
24
+ message?: React.ReactNode;
25
+ reaction?: React.ReactNode;
26
+ toolbox?: React.ReactNode;
27
+ };
28
+
29
+ export const MessageToolboxWrapper = memo(
30
+ ({
31
+ isOutgoing,
32
+ rect,
33
+ message,
34
+ reaction,
35
+ toolbox,
36
+ }: MessageToolboxWrapperProps) => {
37
+ const insets = useSafeAreaInsets();
38
+ const reactionHeight = useSharedValue(0);
39
+ const toolboxHeight = useSharedValue(0);
40
+ const topOffset = Platform.OS === 'android' ? insets.top : 0;
41
+ const { height: windowHeight } = useWindowDimensions();
42
+
43
+ const onToolboxLayout = useCallback(
44
+ ({ nativeEvent }: LayoutChangeEvent) => {
45
+ toolboxHeight.value = nativeEvent.layout.height;
46
+ },
47
+ [toolboxHeight]
48
+ );
49
+
50
+ const onReactionLayout = useCallback(
51
+ ({ nativeEvent }: LayoutChangeEvent) => {
52
+ reactionHeight.value = nativeEvent.layout.height;
53
+ },
54
+ [reactionHeight]
55
+ );
56
+
57
+ const translateY = useDerivedValue(() => {
58
+ const menuGap =
59
+ (reactionHeight.value > 0 ? MENU_GAP : 0) +
60
+ (toolboxHeight.value > 0 ? MENU_GAP : 0);
61
+
62
+ const overflowBottom =
63
+ Math.max(rect.y, insets.top) +
64
+ topOffset +
65
+ rect.height +
66
+ toolboxHeight.value +
67
+ (insets.bottom || 32) +
68
+ menuGap -
69
+ windowHeight;
70
+
71
+ return withTiming(Math.min(-overflowBottom, 0));
72
+ });
73
+
74
+ const containerStyles = useAnimatedStyle(() => ({
75
+ top: Math.max(rect.y, insets.top) + topOffset,
76
+ left: rect.x,
77
+ gap: MENU_GAP,
78
+ transform: [{ translateY: translateY.value }],
79
+ }));
80
+
81
+ return (
82
+ <Animated.View
83
+ pointerEvents="box-none"
84
+ style={[
85
+ isOutgoing
86
+ ? messageStyles.messageRowRight
87
+ : messageStyles.messageRowLeft,
88
+ containerStyles,
89
+ ]}
90
+ >
91
+ <View pointerEvents="none">{message}</View>
92
+ <View onLayout={onReactionLayout}>{reaction}</View>
93
+ <View onLayout={onToolboxLayout}>{toolbox}</View>
94
+ </Animated.View>
95
+ );
96
+ }
97
+ );
98
+
99
+ MessageToolboxWrapper.displayName = 'MessageToolboxWrapper';
@@ -0,0 +1,56 @@
1
+ import { MessageStatus, MessageType } from '@droppii/openim-rn-client-sdk';
2
+ import type { DMessageItem } from '../../../types/chat';
3
+ import { getMessageText } from '../../../utils/legendListMessage';
4
+
5
+ // Copy message content is only offered for text and link messages.
6
+ const COPYABLE_TEXT_TYPES: number[] = [
7
+ MessageType.TextMessage,
8
+ MessageType.AtTextMessage,
9
+ MessageType.QuoteMessage,
10
+ MessageType.UrlTextMessage,
11
+ ];
12
+
13
+ export const isMessageRecalled = (message: DMessageItem): boolean =>
14
+ message.contentType === MessageType.RevokeMessage;
15
+
16
+ export const isMessageSucceeded = (message: DMessageItem): boolean =>
17
+ message.status !== MessageStatus.Failed &&
18
+ message.status !== MessageStatus.Sending;
19
+
20
+ // Failed or recalled messages never offer either copy action.
21
+ export const isEligibleForToolbox = (message: DMessageItem): boolean =>
22
+ isMessageSucceeded(message) && !isMessageRecalled(message);
23
+
24
+ export const isCopyMessageSupported = (message: DMessageItem): boolean =>
25
+ isEligibleForToolbox(message) &&
26
+ COPYABLE_TEXT_TYPES.includes(message.contentType);
27
+
28
+ // All message types are supported, as long as the message is eligible.
29
+ export const isCopyMessageLinkSupported = (message: DMessageItem): boolean =>
30
+ isEligibleForToolbox(message);
31
+
32
+ export const isRetrySupported = (message: DMessageItem): boolean =>
33
+ message.status === MessageStatus.Failed;
34
+
35
+ export const getCopyableMessageText = (message: DMessageItem): string => {
36
+ if (message.contentType === MessageType.UrlTextMessage) {
37
+ return message.urlTextElem?.content ?? '';
38
+ }
39
+ return getMessageText(message);
40
+ };
41
+
42
+ export const useMessageToolboxItems = (message?: DMessageItem) => {
43
+ if (message == null) {
44
+ return {};
45
+ }
46
+
47
+ const showCopyMessage = isCopyMessageSupported(message);
48
+ const showCopyMessageLink = isCopyMessageLinkSupported(message);
49
+ const showRetryMessage = isRetrySupported(message);
50
+
51
+ return {
52
+ showCopyMessage,
53
+ showCopyMessageLink,
54
+ showRetryMessage,
55
+ };
56
+ };
@@ -0,0 +1,13 @@
1
+ import type { DMessageItem } from '../../../types/chat';
2
+
3
+ export interface MeasuredRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+
10
+ export interface MessageToolboxRef {
11
+ show: (message: DMessageItem, rect: MeasuredRect) => void;
12
+ hide: () => void;
13
+ }
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import type { MediaItem } from '../../hooks/useMediaViewer';
3
3
  import type { DMessageItem } from '../../types/chat';
4
4
  import type { ConversationItem } from '@droppii/openim-rn-client-sdk';
5
+ import type { MeasuredRect } from './messageToolbox/types';
5
6
 
6
7
  export type DChatActionIconProvider = 'MaterialCommunityIcons' | 'DroppiiNew';
7
8
 
@@ -72,6 +73,7 @@ export interface ChatListProps {
72
73
  hasMoreNewer?: boolean;
73
74
  onMediaPress?: (items: MediaItem[], index?: number) => void;
74
75
  onPressUrl?: (url: string) => void;
76
+ onLongPress?: (message: DMessageItem, rect: MeasuredRect) => void;
75
77
  }
76
78
 
77
79
  export interface ChatDetailProps extends Omit<
@@ -28,6 +28,11 @@ const i18nKey: Record<string, string> = {
28
28
  'chat:retry_failed': 'Thử lại thất bại',
29
29
  'chat:upload_in_progress': 'Đang tải ảnh lên',
30
30
  'chat:wait_or_cancel': 'Vui lòng chờ hoặc hủy bỏ',
31
+ 'chat:copy_message': 'Sao chép tin nhắn',
32
+ 'chat:copy_message_link': 'Sao chép đường dẫn tin nhắn',
33
+ 'chat:copy_message_success': 'Sao chép tin nhắn thành công',
34
+ 'chat:copy_message_link_success': 'Sao chép đường dẫn tin nhắn thành công',
35
+ 'chat:retry_message': 'Gửi lại',
31
36
  'msg_type_image': '[Hình ảnh]',
32
37
  'msg_type_voice': '[Tin nhắn thoại]',
33
38
  'msg_type_video': '[Video]',
@@ -0,0 +1,23 @@
1
+ const hexToObject = (hex: string) => {
2
+ const [_, r = 0, g = 0, b = 0] =
3
+ /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})/i
4
+ .exec(hex)
5
+ ?.slice(0, 4)
6
+ .map((x) => parseInt(x, 16)) ?? [];
7
+
8
+ return { r, g, b };
9
+ };
10
+
11
+ export function opacityToSolid(
12
+ foregroundColor: string,
13
+ backgroundColor: string,
14
+ alpha: number
15
+ ) {
16
+ const fg = hexToObject(foregroundColor);
17
+ const bg = hexToObject(backgroundColor);
18
+ const r = Math.round(fg.r * alpha + bg.r * (1 - alpha));
19
+ const g = Math.round(fg.g * alpha + bg.g * (1 - alpha));
20
+ const b = Math.round(fg.b * alpha + bg.b * (1 - alpha));
21
+
22
+ return `rgb(${r}, ${g}, ${b})`;
23
+ }