@droppii-org/chat-mobile 0.2.38 → 0.2.40

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 (53) hide show
  1. package/lib/module/components/ThreadCard/ThreadCard.js +2 -1
  2. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  3. package/lib/module/components/messages/quotedMessage/index.js +7 -3
  4. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  5. package/lib/module/core/useChatListener.js +15 -5
  6. package/lib/module/core/useChatListener.js.map +1 -1
  7. package/lib/module/screens/chat-detail/ChatCurrentQuotedMessage.js +4 -2
  8. package/lib/module/screens/chat-detail/ChatCurrentQuotedMessage.js.map +1 -1
  9. package/lib/module/screens/chat-detail/PinnedMessageBar.js +8 -3
  10. package/lib/module/screens/chat-detail/PinnedMessageBar.js.map +1 -1
  11. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +80 -110
  12. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  13. package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js +179 -0
  14. package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js.map +1 -0
  15. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +18 -6
  16. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  17. package/lib/module/translation/resources/i18n.js +3 -2
  18. package/lib/module/translation/resources/i18n.js.map +1 -1
  19. package/lib/module/utils/UIUtils.js +4 -2
  20. package/lib/module/utils/UIUtils.js.map +1 -1
  21. package/lib/module/utils/messageUtils.js +29 -5
  22. package/lib/module/utils/messageUtils.js.map +1 -1
  23. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  24. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  25. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  26. package/lib/typescript/src/screens/chat-detail/ChatCurrentQuotedMessage.d.ts.map +1 -1
  27. package/lib/typescript/src/screens/chat-detail/PinnedMessageBar.d.ts +1 -1
  28. package/lib/typescript/src/screens/chat-detail/PinnedMessageBar.d.ts.map +1 -1
  29. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts +1 -1
  30. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  31. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheetBackup.d.ts +9 -0
  32. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheetBackup.d.ts.map +1 -0
  33. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +2 -1
  34. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  35. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  36. package/lib/typescript/src/types/chat.d.ts +1 -0
  37. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  38. package/lib/typescript/src/utils/UIUtils.d.ts.map +1 -1
  39. package/lib/typescript/src/utils/messageUtils.d.ts +7 -0
  40. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/ThreadCard/ThreadCard.tsx +5 -1
  43. package/src/components/messages/quotedMessage/index.tsx +7 -3
  44. package/src/core/useChatListener.ts +29 -6
  45. package/src/screens/chat-detail/ChatCurrentQuotedMessage.tsx +5 -2
  46. package/src/screens/chat-detail/PinnedMessageBar.tsx +19 -5
  47. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +118 -153
  48. package/src/screens/chat-detail/PinnedMessagesSheetBackup.tsx +253 -0
  49. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +26 -13
  50. package/src/translation/resources/i18n.ts +4 -2
  51. package/src/types/chat.ts +1 -1
  52. package/src/utils/UIUtils.ts +3 -1
  53. package/src/utils/messageUtils.ts +39 -6
@@ -0,0 +1,253 @@
1
+ import {
2
+ forwardRef,
3
+ memo,
4
+ useCallback,
5
+ useImperativeHandle,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import type { ReactNode } from 'react';
10
+ import { Modal, Pressable, StyleSheet, type View } from 'react-native';
11
+ import type { MessageItem } from '@droppii/openim-rn-client-sdk';
12
+ import { KContainer, KLabel, KColors } from '@droppii/libs';
13
+ import { UIUtils } from '../../utils/UIUtils';
14
+ import { trans } from '../../translation';
15
+ import { usePinnedMessageStore } from '../../store/pinnedMessage';
16
+ import { LegendChatMessage } from './legend/LegendChatMessage';
17
+ import { formatMessageTime, messageStyles } from './legend/messageTypes';
18
+ import { MessageToolboxWrapper } from './messageToolbox/MessageToolboxWrapper';
19
+ import { MessageToolboxMenu } from './messageToolbox/MessageToolboxMenu';
20
+ import type { MeasuredRect } from './messageToolbox/types';
21
+
22
+ interface PinnedMessageToolboxRef {
23
+ show: (message: MessageItem, rect: MeasuredRect) => void;
24
+ hide: () => void;
25
+ }
26
+
27
+ interface PinnedMessageToolboxOverlayProps {
28
+ currentUserId?: string;
29
+ unpinMessage: (message: MessageItem) => void | Promise<void>;
30
+ customMessageItemFactory?: (item: MessageItem) => ReactNode;
31
+ }
32
+
33
+ // Mirrors MessageToolboxOverlay (used for the main chat list's long-press
34
+ // menu) but restricted to the 2 actions relevant to an already-pinned
35
+ // message: unpin and jump to the original message.
36
+ const PinnedMessageToolboxOverlay = memo(
37
+ forwardRef<PinnedMessageToolboxRef, PinnedMessageToolboxOverlayProps>(
38
+ ({ currentUserId, unpinMessage, customMessageItemFactory }, ref) => {
39
+ const [visible, setVisible] = useState(false);
40
+ const [message, setMessage] = useState<MessageItem | undefined>(
41
+ undefined
42
+ );
43
+ const [rect, setRect] = useState<MeasuredRect | null>(null);
44
+
45
+ const hide = useCallback(() => {
46
+ setVisible(false);
47
+ setMessage(undefined);
48
+ setRect(null);
49
+ }, []);
50
+
51
+ const show = useCallback((msg: MessageItem, msgRect: MeasuredRect) => {
52
+ setMessage(msg);
53
+ setRect(msgRect);
54
+ setVisible(true);
55
+ }, []);
56
+
57
+ useImperativeHandle(ref, () => ({ show, hide }), [show, hide]);
58
+
59
+ const handleUnpinMessage = useCallback(() => {
60
+ if (!message) return;
61
+ hide();
62
+ unpinMessage(message);
63
+ }, [message, hide, unpinMessage]);
64
+
65
+ const handleViewOriginalMessage = useCallback(() => {
66
+ if (!message) return;
67
+ hide();
68
+ // TODO: implement navigating to / highlighting the original message
69
+ }, [message, hide]);
70
+
71
+ if (!visible || !message || !rect) {
72
+ return null;
73
+ }
74
+
75
+ const isOutgoing = message.sendID === currentUserId;
76
+
77
+ return (
78
+ <Modal
79
+ visible={visible}
80
+ transparent
81
+ statusBarTranslucent
82
+ animationType="fade"
83
+ onRequestClose={hide}
84
+ >
85
+ <Pressable style={styles.backdrop} onPress={hide} />
86
+
87
+ <MessageToolboxWrapper
88
+ rect={rect}
89
+ isOutgoing={isOutgoing}
90
+ message={
91
+ <LegendChatMessage
92
+ message={message}
93
+ isOutgoing={isOutgoing}
94
+ createdAtTime={message.sendTime || message.createTime}
95
+ customMessageItemFactory={customMessageItemFactory}
96
+ />
97
+ }
98
+ toolbox={
99
+ <MessageToolboxMenu
100
+ showUnpinMessage
101
+ showViewOriginalMessage
102
+ onUnpinMessage={handleUnpinMessage}
103
+ onViewOriginalMessage={handleViewOriginalMessage}
104
+ />
105
+ }
106
+ />
107
+ </Modal>
108
+ );
109
+ }
110
+ )
111
+ );
112
+
113
+ PinnedMessageToolboxOverlay.displayName = 'PinnedMessageToolboxOverlay';
114
+
115
+ const styles = StyleSheet.create({
116
+ backdrop: {
117
+ position: 'absolute',
118
+ top: 0,
119
+ left: 0,
120
+ right: 0,
121
+ bottom: 0,
122
+ backgroundColor: 'rgba(0,0,0,0.75)',
123
+ },
124
+ });
125
+
126
+ interface PinnedMessagesSheetContentProps {
127
+ currentUserId?: string;
128
+ unpinMessage: (message: MessageItem) => void | Promise<void>;
129
+ customMessageItemFactory?: (item: MessageItem) => ReactNode;
130
+ }
131
+
132
+ const PinnedMessagesSheetContent = memo(
133
+ ({
134
+ currentUserId,
135
+ unpinMessage,
136
+ customMessageItemFactory,
137
+ }: PinnedMessagesSheetContentProps) => {
138
+ const messages = usePinnedMessageStore((state) => state.messages);
139
+ const toolboxRef = useRef<PinnedMessageToolboxRef>(null);
140
+ const rowRefsMap = useRef<Map<string, View | null>>(new Map());
141
+
142
+ const handleUnpinMessage = useCallback(
143
+ async (message: MessageItem) => {
144
+ await unpinMessage(message);
145
+ if (usePinnedMessageStore.getState().messages.length === 0) {
146
+ UIUtils.bottomSheet.dismiss();
147
+ }
148
+ },
149
+ [unpinMessage]
150
+ );
151
+
152
+ const handleLongPress = useCallback(
153
+ (messageId: string, message: MessageItem) => {
154
+ const node = rowRefsMap.current.get(messageId);
155
+ node?.measureInWindow((x, y, width, height) => {
156
+ toolboxRef.current?.show(message, { x, y, width, height });
157
+ });
158
+ },
159
+ []
160
+ );
161
+
162
+ return (
163
+ <KContainer.View paddingV="0.5rem">
164
+ <KLabel.Text
165
+ typo="TextNmNormal"
166
+ color={KColors.gray.normal}
167
+ paddingH="0.75rem"
168
+ marginB="0.75rem"
169
+ >
170
+ {trans('chat:pinned_messages_hint')}
171
+ </KLabel.Text>
172
+
173
+ {messages.map((message) => {
174
+ const isOutgoing = message.sendID === currentUserId;
175
+ const messageId = message.clientMsgID || message.serverMsgID;
176
+ const createdAtTime = message.sendTime || message.createTime;
177
+ const timeLabel = formatMessageTime(createdAtTime);
178
+
179
+ return (
180
+ <KContainer.View
181
+ key={messageId}
182
+ ref={(node: View | null) => {
183
+ if (node) rowRefsMap.current.set(messageId, node);
184
+ else rowRefsMap.current.delete(messageId);
185
+ }}
186
+ style={
187
+ isOutgoing
188
+ ? messageStyles.messageRowRight
189
+ : messageStyles.messageRowLeft
190
+ }
191
+ >
192
+ <LegendChatMessage
193
+ message={message}
194
+ isOutgoing={isOutgoing}
195
+ createdAtTime={createdAtTime}
196
+ onLongPress={() => handleLongPress(messageId, message)}
197
+ customMessageItemFactory={customMessageItemFactory}
198
+ />
199
+ {!!timeLabel && (
200
+ <KLabel.Text
201
+ typo="TextXsNormal"
202
+ color={KColors.gray.light}
203
+ marginT="0.25rem"
204
+ >
205
+ {timeLabel}
206
+ </KLabel.Text>
207
+ )}
208
+ </KContainer.View>
209
+ );
210
+ })}
211
+
212
+ <PinnedMessageToolboxOverlay
213
+ ref={toolboxRef}
214
+ currentUserId={currentUserId}
215
+ unpinMessage={handleUnpinMessage}
216
+ customMessageItemFactory={customMessageItemFactory}
217
+ />
218
+ </KContainer.View>
219
+ );
220
+ }
221
+ );
222
+
223
+ PinnedMessagesSheetContent.displayName = 'PinnedMessagesSheetContent';
224
+
225
+ export interface OpenPinnedMessagesSheetParams {
226
+ currentUserId?: string;
227
+ unpinMessage: (message: MessageItem) => void | Promise<void>;
228
+ customMessageItemFactory?: (item: MessageItem) => ReactNode;
229
+ }
230
+
231
+ export const openPinnedMessagesSheet = ({
232
+ currentUserId,
233
+ unpinMessage,
234
+ customMessageItemFactory,
235
+ }: OpenPinnedMessagesSheetParams) => {
236
+ UIUtils.bottomSheet.open({
237
+ header: {
238
+ title: { text: trans('chat:pinned_messages_title') },
239
+ alignment: 'left',
240
+ showCloseButton: true,
241
+ },
242
+ touchOutsideToDismiss: true,
243
+ body: {
244
+ renderContent: () => (
245
+ <PinnedMessagesSheetContent
246
+ currentUserId={currentUserId}
247
+ unpinMessage={unpinMessage}
248
+ customMessageItemFactory={customMessageItemFactory}
249
+ />
250
+ ),
251
+ },
252
+ });
253
+ };
@@ -31,6 +31,7 @@ interface LegendChatMessageProps {
31
31
  onMediaPress?: (items: MediaItem[], index?: number) => void;
32
32
  onPressUrl?: (url: string) => void;
33
33
  onRetry?: (message: MessageItem) => void;
34
+ onPress?: () => void;
34
35
  onLongPress?: () => void;
35
36
  customMessageItemFactory?: (m: MessageItem) => ReactNode;
36
37
  }
@@ -66,6 +67,7 @@ export const LegendChatMessage = memo(
66
67
  onMediaPress,
67
68
  onPressUrl,
68
69
  onRetry,
70
+ onPress,
69
71
  onLongPress,
70
72
  customMessageItemFactory,
71
73
  }: LegendChatMessageProps) => {
@@ -91,7 +93,7 @@ export const LegendChatMessage = memo(
91
93
  onRetry={onRetry}
92
94
  />
93
95
  <KContainer.View style={styles.content}>
94
- <LongPressWrapper onLongPress={onLongPress}>
96
+ <LongPressWrapper onPress={onPress} onLongPress={onLongPress}>
95
97
  <MessageComponent
96
98
  message={message}
97
99
  isOutgoing={isOutgoing}
@@ -139,29 +141,40 @@ const styles = StyleSheet.create({
139
141
  elevation: 3,
140
142
  borderWidth: 1,
141
143
  borderColor: KColors.white,
144
+ zIndex: Number.MAX_SAFE_INTEGER,
142
145
  },
143
146
  });
144
147
 
145
148
  const LongPressWrapper = ({
146
149
  children,
150
+ onPress,
147
151
  onLongPress,
148
- }: PropsWithChildren<{ onLongPress?: () => void }>) => {
149
- const longPressGesture = useMemo(
150
- () =>
151
- onLongPress
152
- ? Gesture.LongPress().onStart(() => {
153
- runOnJS(onLongPress)();
154
- })
155
- : null,
156
- [onLongPress]
157
- );
152
+ }: PropsWithChildren<{ onPress?: () => void; onLongPress?: () => void }>) => {
153
+ const gesture = useMemo(() => {
154
+ const gestures = [
155
+ onLongPress &&
156
+ Gesture.LongPress().onStart(() => {
157
+ runOnJS(onLongPress)();
158
+ }),
159
+ onPress &&
160
+ Gesture.Tap().onStart(() => {
161
+ runOnJS(onPress)();
162
+ }),
163
+ ].filter(Boolean) as ReturnType<typeof Gesture.Tap>[];
164
+
165
+ if (!gestures.length) {
166
+ return null;
167
+ }
168
+
169
+ return gestures.length > 1 ? Gesture.Race(...gestures) : gestures[0];
170
+ }, [onPress, onLongPress]);
158
171
 
159
- if (!longPressGesture) {
172
+ if (!gesture) {
160
173
  return children;
161
174
  }
162
175
 
163
176
  return (
164
- <GestureDetector gesture={longPressGesture}>
177
+ <GestureDetector gesture={gesture}>
165
178
  <KContainer.View>{children}</KContainer.View>
166
179
  </GestureDetector>
167
180
  );
@@ -43,15 +43,17 @@ const i18nKey: Record<string, string> = {
43
43
  'chat:pinned_image_subtitle': 'Hình ảnh từ {{sender}}',
44
44
  'chat:pinned_video_subtitle': 'Video từ {{sender}}',
45
45
  'chat:pinned_file_subtitle': 'Tài liệu từ {{sender}}',
46
- 'chat:pinned_link_subtitle': 'Liên kết từ {{sender}}',
46
+ 'chat:pinned_link_subtitle': 'Từ {{sender}}',
47
47
  'chat:pin_limit_title': 'Không thể ghim thêm tin nhắn',
48
48
  'chat:pin_limit_desc':
49
49
  'Chỉ được ghim tối đa 5 tin nhắn. Vui lòng bỏ ghim một tin nhắn hiện có trước khi ghim tin nhắn mới.',
50
50
  'chat:pin_limit_primary': 'Đã hiểu',
51
51
  'chat:pinned_messages_title': 'Tin nhắn đã ghim',
52
52
  'chat:pinned_messages_hint':
53
- 'Nhấn giữ tin nhắn để chọn "Bỏ Ghim" hoặc "Xem tin nhắn gốc"',
53
+ 'Nhấn vào tin nhắn để xem tin nhắn gốc, nhấn vào biểu tượng để bỏ ghim',
54
54
  'chat:view_original_message': 'Xem tin nhắn gốc',
55
+ 'chat:scroll_to_message_todo':
56
+ 'Tính năng di chuyển đến tin nhắn gốc sẽ sớm được cập nhật',
55
57
  'msg_type_image': '[Hình ảnh]',
56
58
  'msg_type_voice': '[Tin nhắn thoại]',
57
59
  'msg_type_video': '[Video]',
package/src/types/chat.ts CHANGED
@@ -28,7 +28,7 @@ export interface ChatUIAdapter {
28
28
  toast?: { open: (params: any) => void };
29
29
  alert?: { open: (params: any) => void };
30
30
  popup?: { open: (params: any) => void };
31
- bottomSheet?: { open: (params: any) => void };
31
+ bottomSheet?: { open: (params: any) => void; dismiss: () => void };
32
32
  }
33
33
 
34
34
  export interface ChatContextType {
@@ -10,7 +10,7 @@ const defaultAdapter: Required<ChatUIAdapter> = {
10
10
  toast: { open: openWithAlert },
11
11
  alert: { open: openWithAlert },
12
12
  popup: { open: openWithAlert },
13
- bottomSheet: { open: openWithAlert },
13
+ bottomSheet: { open: openWithAlert, dismiss: () => {} },
14
14
  };
15
15
 
16
16
  let _adapter: ChatUIAdapter = {};
@@ -32,5 +32,7 @@ export const UIUtils: Required<ChatUIAdapter> = {
32
32
  bottomSheet: {
33
33
  open: (params) =>
34
34
  (_adapter.bottomSheet ?? defaultAdapter.bottomSheet).open(params),
35
+ dismiss: () =>
36
+ (_adapter.bottomSheet ?? defaultAdapter.bottomSheet).dismiss(),
35
37
  },
36
38
  };
@@ -12,6 +12,7 @@ import { formatDuration } from './dateTimeUtils';
12
12
  import { getFileIcon, getMimeType } from './imageUtils';
13
13
  import { trans } from '../translation';
14
14
  import type { IUrlMetadata } from '../types/common';
15
+ import type { TextProps } from 'react-native';
15
16
 
16
17
  export const isMessageRevoked = (message: MessageItem): boolean =>
17
18
  message.contentType === MessageType.RevokeMessage;
@@ -46,14 +47,13 @@ export const getMessagePreview = (message?: MessageItem): string => {
46
47
  return '[Video]';
47
48
  }
48
49
 
49
- case MessageType.FileMessage:
50
- try {
51
- const fileData = JSON.parse(message.content);
52
- const fileName = fileData.fileName || fileData.name || 'File';
50
+ case MessageType.FileMessage: {
51
+ const fileName = message?.fileElem?.fileName;
52
+ if (fileName) {
53
53
  return `[File] ${fileName}`;
54
- } catch {
55
- return '[File]';
56
54
  }
55
+ return '[File]';
56
+ }
57
57
 
58
58
  case MessageType.MergeMessage:
59
59
  // Show image count for merged messages containing images
@@ -97,6 +97,27 @@ export const getMessagePreview = (message?: MessageItem): string => {
97
97
  }
98
98
  };
99
99
 
100
+ export const getMessagePreviewTextProps = (
101
+ message?: MessageItem
102
+ ): TextProps => {
103
+ if (!message) return {};
104
+ switch (message.contentType) {
105
+ case MessageType.FileMessage:
106
+ return {
107
+ ellipsizeMode: 'middle',
108
+ };
109
+ default:
110
+ return {};
111
+ }
112
+ };
113
+
114
+ export const getMessagePreviewParts = (
115
+ message?: MessageItem
116
+ ): { text: string; textProps: TextProps } => ({
117
+ text: getMessagePreview(message),
118
+ textProps: getMessagePreviewTextProps(message),
119
+ });
120
+
100
121
  export type PinnedMessageMediaType = 'image' | 'video' | 'file' | 'text';
101
122
 
102
123
  export interface PinnedMessagePreview {
@@ -233,3 +254,15 @@ export const parseUrlMetadata = (ex?: string): IUrlMetadata | undefined => {
233
254
  return undefined;
234
255
  }
235
256
  };
257
+
258
+ export const truncateMiddle = (name: string, maxLength: number): string => {
259
+ const dotIndex = name.lastIndexOf('.');
260
+ const baseName = dotIndex !== -1 ? name.slice(0, dotIndex) : name;
261
+ const ext = dotIndex !== -1 ? name.slice(dotIndex) : '';
262
+
263
+ if (baseName.length <= maxLength) return name;
264
+
265
+ const headLen = Math.ceil((maxLength - 3) / 2);
266
+ const tailLen = Math.floor((maxLength - 3) / 2);
267
+ return `${baseName.slice(0, headLen)}...${tailLen > 0 ? baseName.slice(-tailLen) : ''}${ext}`;
268
+ };