@droppii-org/chat-mobile 0.2.17 → 0.2.18

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 (96) hide show
  1. package/lib/module/assets/images/icon_excel.png +0 -0
  2. package/lib/module/assets/images/icon_img.png +0 -0
  3. package/lib/module/assets/images/icon_pdf.png +0 -0
  4. package/lib/module/assets/images/icon_txt.png +0 -0
  5. package/lib/module/assets/images/icon_word.png +0 -0
  6. package/lib/module/assets/images/index.js +5 -0
  7. package/lib/module/assets/images/index.js.map +1 -1
  8. package/lib/module/components/AttachmentPreview/FilePreview.js +107 -0
  9. package/lib/module/components/AttachmentPreview/FilePreview.js.map +1 -0
  10. package/lib/module/components/AttachmentPreview/MediaPreview.js +212 -0
  11. package/lib/module/components/AttachmentPreview/MediaPreview.js.map +1 -0
  12. package/lib/module/components/AttachmentPreview/index.js +49 -0
  13. package/lib/module/components/AttachmentPreview/index.js.map +1 -0
  14. package/lib/module/components/messages/fileMessage/index.js +122 -12
  15. package/lib/module/components/messages/fileMessage/index.js.map +1 -1
  16. package/lib/module/hooks/useFileAttachment.js +116 -0
  17. package/lib/module/hooks/useFileAttachment.js.map +1 -0
  18. package/lib/module/hooks/useFileDownload.js +175 -0
  19. package/lib/module/hooks/useFileDownload.js.map +1 -0
  20. package/lib/module/index.js +1 -1
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/screens/chat-detail/ChatComposer.js +44 -36
  23. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  24. package/lib/module/screens/chat-detail/hooks/useAttachmentMapping.js +51 -0
  25. package/lib/module/screens/chat-detail/hooks/useAttachmentMapping.js.map +1 -0
  26. package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js +39 -13
  27. package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js.map +1 -1
  28. package/lib/module/services/attachmentHandlers/fileAttachmentHandler.js +6 -6
  29. package/lib/module/services/attachmentHandlers/fileAttachmentHandler.js.map +1 -1
  30. package/lib/module/services/attachmentHandlers/index.js +1 -1
  31. package/lib/module/services/attachmentHandlers/index.js.map +1 -1
  32. package/lib/module/translation/resources/i18n.js +9 -0
  33. package/lib/module/translation/resources/i18n.js.map +1 -1
  34. package/lib/module/utils/device.js +32 -0
  35. package/lib/module/utils/device.js.map +1 -1
  36. package/lib/module/utils/imageUtils.js +44 -28
  37. package/lib/module/utils/imageUtils.js.map +1 -1
  38. package/lib/typescript/src/assets/images/index.d.ts +5 -0
  39. package/lib/typescript/src/assets/images/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/AttachmentPreview/FilePreview.d.ts +6 -0
  41. package/lib/typescript/src/components/AttachmentPreview/FilePreview.d.ts.map +1 -0
  42. package/lib/typescript/src/components/AttachmentPreview/MediaPreview.d.ts +9 -0
  43. package/lib/typescript/src/components/AttachmentPreview/MediaPreview.d.ts.map +1 -0
  44. package/lib/typescript/src/components/AttachmentPreview/index.d.ts +9 -0
  45. package/lib/typescript/src/components/AttachmentPreview/index.d.ts.map +1 -0
  46. package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
  47. package/lib/typescript/src/hooks/useFileAttachment.d.ts +20 -0
  48. package/lib/typescript/src/hooks/useFileAttachment.d.ts.map +1 -0
  49. package/lib/typescript/src/hooks/useFileDownload.d.ts +15 -0
  50. package/lib/typescript/src/hooks/useFileDownload.d.ts.map +1 -0
  51. package/lib/typescript/src/hooks/useImageAttachment.d.ts +1 -2
  52. package/lib/typescript/src/hooks/useImageAttachment.d.ts.map +1 -1
  53. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  54. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentMapping.d.ts +10 -0
  55. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentMapping.d.ts.map +1 -0
  56. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts +4 -1
  57. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts.map +1 -1
  58. package/lib/typescript/src/services/attachmentHandlers/fileAttachmentHandler.d.ts +2 -2
  59. package/lib/typescript/src/services/attachmentHandlers/fileAttachmentHandler.d.ts.map +1 -1
  60. package/lib/typescript/src/services/attachmentHandlers/index.d.ts +1 -1
  61. package/lib/typescript/src/services/attachmentHandlers/index.d.ts.map +1 -1
  62. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  63. package/lib/typescript/src/types/attachment.d.ts +22 -1
  64. package/lib/typescript/src/types/attachment.d.ts.map +1 -1
  65. package/lib/typescript/src/utils/device.d.ts +7 -0
  66. package/lib/typescript/src/utils/device.d.ts.map +1 -1
  67. package/lib/typescript/src/utils/imageUtils.d.ts +1 -0
  68. package/lib/typescript/src/utils/imageUtils.d.ts.map +1 -1
  69. package/package.json +5 -1
  70. package/src/assets/images/icon_excel.png +0 -0
  71. package/src/assets/images/icon_img.png +0 -0
  72. package/src/assets/images/icon_pdf.png +0 -0
  73. package/src/assets/images/icon_txt.png +0 -0
  74. package/src/assets/images/icon_word.png +0 -0
  75. package/src/assets/images/index.ts +5 -0
  76. package/src/components/AttachmentPreview/FilePreview.tsx +137 -0
  77. package/src/components/AttachmentPreview/MediaPreview.tsx +238 -0
  78. package/src/components/AttachmentPreview/index.tsx +68 -0
  79. package/src/components/messages/fileMessage/index.tsx +156 -13
  80. package/src/hooks/useFileAttachment.ts +163 -0
  81. package/src/hooks/useFileDownload.ts +200 -0
  82. package/src/hooks/useImageAttachment.ts +1 -1
  83. package/src/screens/chat-detail/ChatComposer.tsx +61 -30
  84. package/src/screens/chat-detail/hooks/useAttachmentMapping.ts +58 -0
  85. package/src/screens/chat-detail/hooks/useAttachmentSendHandler.ts +50 -11
  86. package/src/services/attachmentHandlers/fileAttachmentHandler.ts +8 -7
  87. package/src/services/attachmentHandlers/index.ts +4 -1
  88. package/src/translation/resources/i18n.ts +9 -0
  89. package/src/types/attachment.ts +23 -1
  90. package/src/utils/device.ts +58 -0
  91. package/src/utils/imageUtils.ts +50 -28
  92. package/lib/module/components/AttachmentPreview.js +0 -252
  93. package/lib/module/components/AttachmentPreview.js.map +0 -1
  94. package/lib/typescript/src/components/AttachmentPreview.d.ts +0 -28
  95. package/lib/typescript/src/components/AttachmentPreview.d.ts.map +0 -1
  96. package/src/components/AttachmentPreview.tsx +0 -310
@@ -21,15 +21,17 @@ import { getAttachmentPanelHeight } from './constants';
21
21
  import type { ChatComposerProps } from './types';
22
22
  import { useLinkPreview } from '../../hooks/useLinkPreview/useLinkPreview';
23
23
  import { useImageAttachment } from '../../hooks/useImageAttachment';
24
+ import { useFileAttachment } from '../../hooks/useFileAttachment';
24
25
  import { useSendAttachment } from '../../hooks/useSendAttachment';
25
26
  import { useChatComposerState } from './hooks/useChatComposerState';
26
27
  import { useChatComposerAnimation } from './hooks/useChatComposerAnimation';
27
28
  import { useAttachmentSendHandler } from './hooks/useAttachmentSendHandler';
28
- import { isVideoFromPicker } from '../../utils/imageUtils';
29
29
  import { useChatDetailContext } from '../../context/ChatDetailContext';
30
30
  import { trans } from '../../translation';
31
31
  import { useRunFlow } from '../../hooks/flows/useFlow';
32
32
  import { ReactFlowsNodeTypes } from '../../types/flows';
33
+ import { useAttachmentMapping } from './hooks/useAttachmentMapping';
34
+ import type { AttachmentType } from '../../types/attachment';
33
35
 
34
36
  export const ChatComposer = memo(
35
37
  ({
@@ -73,6 +75,15 @@ export const ChatComposer = memo(
73
75
  clearAllImages,
74
76
  } = useImageAttachment();
75
77
 
78
+ // File attachment
79
+ const {
80
+ selectedFiles,
81
+ isFilePreviewVisible,
82
+ handleFileAttach,
83
+ handleFileRemove,
84
+ clearAllFiles,
85
+ } = useFileAttachment();
86
+
76
87
  // Send attachment
77
88
  const { send: sendAttachments } = useSendAttachment();
78
89
 
@@ -95,8 +106,10 @@ export const ChatComposer = memo(
95
106
  // Attachment send handler
96
107
  const { handleSend: handleAttachmentSend } = useAttachmentSendHandler({
97
108
  selectedImages,
109
+ selectedFiles,
98
110
  sendAttachments,
99
111
  clearAllImages,
112
+ clearAllFiles,
100
113
  onSend,
101
114
  onMessageSent,
102
115
  textMessage: (value ?? '').trim(),
@@ -135,15 +148,18 @@ export const ChatComposer = memo(
135
148
  onAttachmentOpenChange: setIsAttachmentOpen,
136
149
  });
137
150
 
138
- // Check if any attachments are currently uploading
151
+ // Check if any attachments are currently uploading or have errors
139
152
  const isUploadingAttachments = useMemo(
140
- () => selectedImages.some((img: any) => img.isUploading),
141
- [selectedImages]
153
+ () =>
154
+ selectedImages.some((img: any) => img.isUploading) ||
155
+ selectedFiles.some((f) => f.isUploading || !!f.error),
156
+ [selectedImages, selectedFiles]
142
157
  );
143
158
 
144
159
  // Send handler - includes attachment and preview link features
145
160
  const handleSend = useCallback(() => {
146
- const hasAttachments = selectedImages.length > 0;
161
+ const hasAttachments =
162
+ selectedImages.length > 0 || selectedFiles.length > 0;
147
163
  const hasText = (value ?? '').trim().length > 0;
148
164
  // Only send metadata if preview is CURRENTLY visible (user didn't dismiss it)
149
165
  const previewMetadata =
@@ -184,6 +200,7 @@ export const ChatComposer = memo(
184
200
  urlMetadata,
185
201
  showLinkPreview,
186
202
  selectedImages,
203
+ selectedFiles,
187
204
  handleAttachmentSend,
188
205
  value,
189
206
  isUploadingAttachments,
@@ -232,9 +249,22 @@ export const ChatComposer = memo(
232
249
  }
233
250
  }, [value, setIsInputWrapped]);
234
251
 
235
- // Debounced image picker (prevent rapid clicks)
252
+ const handleImageAttachRef = useRef(handleImageAttach);
253
+ handleImageAttachRef.current = handleImageAttach;
236
254
  const debouncedImagePickerRef = useRef(
237
- debounce(handleImageAttach, 300, { leading: true, trailing: false })
255
+ debounce(() => handleImageAttachRef.current(), 300, {
256
+ leading: true,
257
+ trailing: false,
258
+ })
259
+ );
260
+
261
+ const handleFileAttachRef = useRef(handleFileAttach);
262
+ handleFileAttachRef.current = handleFileAttach;
263
+ const debouncedFilePickerRef = useRef(
264
+ debounce(() => handleFileAttachRef.current(), 300, {
265
+ leading: true,
266
+ trailing: false,
267
+ })
238
268
  );
239
269
 
240
270
  // Attachment action handler
@@ -242,6 +272,8 @@ export const ChatComposer = memo(
242
272
  async (action: any) => {
243
273
  if (action.id === 'gallery') {
244
274
  debouncedImagePickerRef.current();
275
+ } else if (action.id === 'file') {
276
+ debouncedFilePickerRef.current();
245
277
  } else {
246
278
  onAttachmentAction?.(action);
247
279
  }
@@ -257,6 +289,24 @@ export const ChatComposer = memo(
257
289
  [insets.bottom, isAttachmentOpen]
258
290
  );
259
291
 
292
+ const attachments = useAttachmentMapping({
293
+ selectedImages,
294
+ selectedFiles,
295
+ });
296
+
297
+ const onRemove = useCallback(
298
+ (type: AttachmentType, data: unknown) => {
299
+ switch (type) {
300
+ case 'file':
301
+ return handleFileRemove(data as string);
302
+
303
+ default:
304
+ return handleImageRemove(data as number);
305
+ }
306
+ },
307
+ [handleImageRemove, handleFileRemove]
308
+ );
309
+
260
310
  if (
261
311
  isBotConversation &&
262
312
  conversationFlow?.flowCurrentStep?.type === ReactFlowsNodeTypes.Start &&
@@ -278,6 +328,7 @@ export const ChatComposer = memo(
278
328
  </KContainer.View>
279
329
  );
280
330
  }
331
+
281
332
  return (
282
333
  <KContainer.View background={'rgba(131, 137, 157, 0.05)'}>
283
334
  <ChatQuickActions
@@ -297,29 +348,9 @@ export const ChatComposer = memo(
297
348
  )}
298
349
 
299
350
  <AttachmentPreview
300
- visible={isImagePreviewVisible}
301
- attachments={selectedImages.map((img: any) => {
302
- const isVideo = isVideoFromPicker(img.image);
303
- return {
304
- type: isVideo ? ('video' as const) : ('image' as const),
305
- metadata: {
306
- path: img.image.path,
307
- filename: img.image.filename,
308
- size: img.image.size,
309
- width: img.image.width,
310
- height: img.image.height,
311
- ...(isVideo && { duration: (img.image as any).duration }),
312
- },
313
- uploadProgress: img.uploadProgress,
314
- error: img.error,
315
- isUploading: img.isUploading,
316
- uploadedUrl: img.uploadedUrl,
317
- ...(isVideo && { thumbnailUrl: img.thumbnailUrl }),
318
- };
319
- })}
320
- onRemove={(type, index) => {
321
- handleImageRemove(index);
322
- }}
351
+ visible={isImagePreviewVisible || isFilePreviewVisible}
352
+ attachments={attachments}
353
+ onRemove={onRemove}
323
354
  />
324
355
 
325
356
  <KContainer.View
@@ -0,0 +1,58 @@
1
+ import { isVideoFromPicker } from '../../../utils/imageUtils';
2
+ import type { ImageWithUploadStatus } from '../../../hooks/useImageAttachment';
3
+ import type { FileWithUploadStatus } from '../../../hooks/useFileAttachment';
4
+ import type { AttachmentItem } from '../../../types/attachment';
5
+ import { useMemo } from 'react';
6
+
7
+ type Props = {
8
+ selectedImages: ImageWithUploadStatus[];
9
+ selectedFiles: FileWithUploadStatus[];
10
+ };
11
+
12
+ const mapMedia = (img: ImageWithUploadStatus): AttachmentItem => {
13
+ const isVideo = isVideoFromPicker(img.image);
14
+ return {
15
+ type: isVideo ? ('video' as const) : ('image' as const),
16
+ metadata: {
17
+ path: img.image.path,
18
+ filename: img.image.filename,
19
+ size: img.image.size,
20
+ width: img.image.width,
21
+ height: img.image.height,
22
+ ...(isVideo && { duration: (img.image as any).duration }),
23
+ },
24
+ uploadProgress: img.uploadProgress,
25
+ error: img.error,
26
+ isUploading: img.isUploading,
27
+ uploadedUrl: img.uploadedUrl,
28
+ ...(isVideo && { thumbnailUrl: img.thumbnailUrl }),
29
+ };
30
+ };
31
+
32
+ const mapFile = (file: FileWithUploadStatus): AttachmentItem => {
33
+ return {
34
+ type: 'file',
35
+ metadata: {
36
+ id: file.id,
37
+ path: file.uri,
38
+ filename: file.name,
39
+ size: file.size,
40
+ mimeType: file.mimeType,
41
+ uri: file.uri,
42
+ },
43
+ uploadProgress: file.uploadProgress,
44
+ error: file.error,
45
+ isUploading: file.isUploading,
46
+ uploadedUrl: file.uploadedUrl,
47
+ };
48
+ };
49
+
50
+ export const useAttachmentMapping = ({
51
+ selectedImages,
52
+ selectedFiles,
53
+ }: Props) => {
54
+ return useMemo(
55
+ () => [...selectedImages.map(mapMedia), ...selectedFiles.map(mapFile)],
56
+ [selectedImages, selectedFiles]
57
+ );
58
+ };
@@ -5,6 +5,7 @@ import VideoThumbnailUtil from '../../../utils/videoThumbnail';
5
5
  import { ImageUploadAPI } from '../../../services/imageUpload';
6
6
  import UIUtils from '../../../utils/ui';
7
7
  import type { DMessageItem } from '../../../types/chat';
8
+ import type { FileWithUploadStatus } from '../../../hooks/useFileAttachment';
8
9
 
9
10
  interface AttachmentItem {
10
11
  image: any;
@@ -16,12 +17,14 @@ interface AttachmentItem {
16
17
 
17
18
  interface UseAttachmentSendHandlerProps {
18
19
  selectedImages: AttachmentItem[];
20
+ selectedFiles: FileWithUploadStatus[];
19
21
  sendAttachments: (
20
22
  items: any[],
21
23
  _: any,
22
24
  onMessageSent?: (message: DMessageItem) => void
23
25
  ) => Promise<boolean>;
24
26
  clearAllImages: () => void;
27
+ clearAllFiles?: () => void;
25
28
  onSend?: () => void;
26
29
  onMessageSent?: (message: DMessageItem) => void;
27
30
  textMessage?: string;
@@ -31,8 +34,10 @@ interface UseAttachmentSendHandlerProps {
31
34
 
32
35
  export const useAttachmentSendHandler = ({
33
36
  selectedImages,
37
+ selectedFiles,
34
38
  sendAttachments,
35
39
  clearAllImages,
40
+ clearAllFiles,
36
41
  onSend,
37
42
  onMessageSent,
38
43
  textMessage,
@@ -316,26 +321,60 @@ export const useAttachmentSendHandler = ({
316
321
  if (success) {
317
322
  clearAllImages();
318
323
  console.log('[ChatComposer] Images cleared after successful send');
324
+ }
325
+ }
319
326
 
320
- // Send text message after attachments (follows attachment priority order)
321
- // Note: urlMetadata is only passed if preview is still visible (not dismissed)
322
- if (textMessage && onSendText) {
323
- console.log('[ChatComposer] Sending text message after attachments', {
324
- hasPreview: !!urlMetadata,
325
- previewUrl: urlMetadata?.url,
326
- });
327
- onSendText(textMessage, urlMetadata);
328
- }
327
+ // Send each file as its own individual message
328
+ const uploadedFiles = selectedFiles.filter(
329
+ (f) => f.uploadedUrl && !f.error
330
+ );
331
+ if (uploadedFiles.length > 0) {
332
+ console.log('[ChatComposer] Sending files individually:', {
333
+ count: uploadedFiles.length,
334
+ });
335
+ for (const file of uploadedFiles) {
336
+ await sendAttachments(
337
+ [
338
+ {
339
+ type: 'file' as const,
340
+ file: { path: file.uri, filename: file.name, size: file.size },
341
+ uploadProgress: 100,
342
+ error: null,
343
+ isUploading: false,
344
+ uploadedUrl: file.uploadedUrl,
345
+ },
346
+ ],
347
+ undefined,
348
+ onMessageSent
349
+ );
329
350
  }
351
+ clearAllFiles?.();
352
+ }
353
+
354
+ // Send text last
355
+ if (textMessage && onSendText) {
356
+ console.log('[ChatComposer] Sending text message after attachments', {
357
+ hasPreview: !!urlMetadata,
358
+ previewUrl: urlMetadata?.url,
359
+ });
360
+ onSendText(textMessage, urlMetadata);
330
361
  return;
331
362
  }
332
363
 
333
- // Send text message only if no attachments
334
- onSend?.();
364
+ const hasAnySent =
365
+ uploadedImages.length > 0 ||
366
+ allUploadedVideos.length > 0 ||
367
+ uploadedFiles.length > 0;
368
+ if (!hasAnySent) {
369
+ onSend?.();
370
+ }
371
+ return;
335
372
  }, [
336
373
  selectedImages,
374
+ selectedFiles,
337
375
  sendAttachments,
338
376
  clearAllImages,
377
+ clearAllFiles,
339
378
  onSend,
340
379
  onMessageSent,
341
380
  textMessage,
@@ -6,12 +6,13 @@ interface FileElem {
6
6
  fileName: string;
7
7
  fileSize: number;
8
8
  fileType: string;
9
- url: string;
9
+ sourceUrl: string;
10
10
  }
11
11
 
12
- export class FileAttachmentHandler implements IAttachmentHandler {
13
- private static readonly MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
12
+ const MB = 1024 * 1024;
13
+ export const MAX_FILE_SIZE = 50 * MB;
14
14
 
15
+ export class FileAttachmentHandler implements IAttachmentHandler {
15
16
  validate(
16
17
  attachment: Attachment
17
18
  ): { valid: true } | { valid: false; reason: string } {
@@ -38,10 +39,10 @@ export class FileAttachmentHandler implements IAttachmentHandler {
38
39
  };
39
40
  }
40
41
 
41
- if (sourceFile.size > FileAttachmentHandler.MAX_FILE_SIZE) {
42
+ if (sourceFile.size > MAX_FILE_SIZE) {
42
43
  return {
43
44
  valid: false,
44
- reason: `File size exceeds maximum (${FileAttachmentHandler.MAX_FILE_SIZE / 1024 / 1024}MB)`,
45
+ reason: `File size exceeds maximum (${MAX_FILE_SIZE / 1024 / 1024}MB)`,
45
46
  };
46
47
  }
47
48
 
@@ -60,7 +61,7 @@ export class FileAttachmentHandler implements IAttachmentHandler {
60
61
  fileName: sourceFile.filename,
61
62
  fileSize: sourceFile.size,
62
63
  fileType: sourceFile.mimeType,
63
- url: uploadedUrl,
64
+ sourceUrl: uploadedUrl,
64
65
  };
65
66
 
66
67
  console.warn('[FileAttachmentHandler] Built payload', {
@@ -68,7 +69,7 @@ export class FileAttachmentHandler implements IAttachmentHandler {
68
69
  fileName: fileElem.fileName,
69
70
  fileSize: fileElem.fileSize,
70
71
  fileType: fileElem.fileType,
71
- url: uploadedUrl.substring(0, 50) + '...',
72
+ sourceUrl: uploadedUrl.substring(0, 50) + '...',
72
73
  });
73
74
 
74
75
  return fileElem;
@@ -7,4 +7,7 @@ export function initializeAttachmentHandlers(): void {
7
7
 
8
8
  export { ImageAttachmentHandler } from './imageAttachmentHandler';
9
9
  export { default as VideoAttachmentHandler } from './videoAttachmentHandler';
10
- export { default as FileAttachmentHandler } from './fileAttachmentHandler';
10
+ export {
11
+ default as FileAttachmentHandler,
12
+ MAX_FILE_SIZE,
13
+ } from './fileAttachmentHandler';
@@ -1,6 +1,15 @@
1
1
  const i18nKey: Record<string, string> = {
2
2
  'thread_card_bot_crm_name': 'Droppii Hỗ Trợ',
3
3
  'thread_card_fallback_name': 'Người dùng',
4
+ 'chat:file_too_large': 'Tệp quá lớn. Tối đa 10MB',
5
+ 'chat:file_max_count': 'Chỉ có thể đính kèm tối đa 3 tệp',
6
+ 'chat:file_pick_failed': 'Không thể chọn tệp',
7
+ 'chat:file_upload_failed': 'Lỗi tải tệp lên',
8
+ 'chat:file_preview_unsupported': 'Không thể xem trước tệp này',
9
+ 'chat:file_downloading': 'Đang tải {{received}}/{{total}} MB',
10
+ 'chat:file_downloaded_success': 'Đã tải thành công {{fileName}}',
11
+ 'chat:file_download_failed': 'Tải tệp thất bại',
12
+ 'chat:file_open_failed': 'Không thể mở tệp này',
4
13
  'chat:image_preview': 'Xem trước ảnh',
5
14
  'chat:image_too_large': 'Ảnh quá lớn. Tối đa 5MB',
6
15
  'chat:video_too_large': 'Video quá lớn. Tối đa 200MB',
@@ -1,4 +1,4 @@
1
- export type AttachmentType = 'image' | 'video' | 'file' | 'audio';
1
+ export type AttachmentType = 'image' | 'video' | 'file' | 'audio' | 'document';
2
2
 
3
3
  export interface BaseAttachment {
4
4
  id: string;
@@ -83,3 +83,25 @@ export interface AttachmentSendResult {
83
83
  mergedMessageId?: string;
84
84
  errors: Record<string, string>;
85
85
  }
86
+
87
+ interface AttachmentMetadata {
88
+ id?: string;
89
+ path: string;
90
+ filename?: string;
91
+ size?: number;
92
+ duration?: number;
93
+ mimeType?: string;
94
+ width?: number;
95
+ height?: number;
96
+ uri?: string;
97
+ }
98
+
99
+ export interface AttachmentItem {
100
+ metadata: AttachmentMetadata;
101
+ uploadProgress: number;
102
+ error: string | null;
103
+ isUploading: boolean;
104
+ uploadedUrl?: string;
105
+ thumbnailUrl?: string;
106
+ type: AttachmentType;
107
+ }
@@ -1,4 +1,18 @@
1
1
  import ImagePicker from 'react-native-image-crop-picker';
2
+ import {
3
+ pick as pickDocument,
4
+ types as documentTypes,
5
+ isErrorWithCode,
6
+ errorCodes,
7
+ keepLocalCopy,
8
+ } from '@react-native-documents/picker';
9
+
10
+ export interface PickedFile {
11
+ uri: string;
12
+ name: string;
13
+ size: number;
14
+ mimeType: string;
15
+ }
2
16
 
3
17
  const DeviceUtils = {
4
18
  openImagePicker: async (multiple: boolean = false) => {
@@ -68,6 +82,50 @@ const DeviceUtils = {
68
82
  throw error;
69
83
  }
70
84
  },
85
+
86
+ openFilePicker: async (
87
+ multiple: boolean = false
88
+ ): Promise<PickedFile[] | null> => {
89
+ try {
90
+ const results = await pickDocument({
91
+ allowMultiSelection: multiple,
92
+ type: [documentTypes.allFiles],
93
+ mode: 'import',
94
+ });
95
+
96
+ // Copy picked files to app's document directory so uploads can access them
97
+ const copies = await keepLocalCopy({
98
+ files: results.map((r) => ({
99
+ uri: r.uri,
100
+ fileName: r.name ?? 'file',
101
+ })) as [
102
+ { uri: string; fileName: string },
103
+ ...{ uri: string; fileName: string }[],
104
+ ],
105
+ destination: 'documentDirectory',
106
+ });
107
+
108
+ const files = results.map((r, i) => ({
109
+ uri: decodeURIComponent(
110
+ copies[i]?.status === 'success' ? copies[i].localUri : r.uri
111
+ ),
112
+ name: r.name ?? 'file',
113
+ size: r.size ?? 0,
114
+ mimeType: r.type ?? 'application/octet-stream',
115
+ }));
116
+
117
+ return files;
118
+ } catch (error: any) {
119
+ if (
120
+ isErrorWithCode(error) &&
121
+ error.code === errorCodes.OPERATION_CANCELED
122
+ ) {
123
+ return null;
124
+ }
125
+ console.error('[File Picker] Error:', error);
126
+ throw error;
127
+ }
128
+ },
71
129
  };
72
130
 
73
131
  export default DeviceUtils;
@@ -1,4 +1,5 @@
1
1
  import type { ImageFile } from '../types/imageUpload';
2
+ import Images from '../assets/images';
2
3
 
3
4
  export const generateUUID = (): string => {
4
5
  return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -17,39 +18,60 @@ export const isVideoFromPicker = (pickedItem: any): boolean => {
17
18
  return mime.startsWith('video/');
18
19
  };
19
20
 
21
+ const mimeMap: Record<string, string> = {
22
+ jpg: 'image/jpeg',
23
+ jpeg: 'image/jpeg',
24
+ png: 'image/png',
25
+ webp: 'image/webp',
26
+ heic: 'image/heic',
27
+ heif: 'image/heif',
28
+ mp4: 'video/mp4',
29
+ mov: 'video/quicktime',
30
+ avi: 'video/x-msvideo',
31
+ mkv: 'video/x-matroska',
32
+ webm: 'video/webm',
33
+ m4v: 'video/mp4',
34
+ flv: 'video/x-flv',
35
+ wmv: 'video/x-ms-wmv',
36
+ mp3: 'audio/mpeg',
37
+ wav: 'audio/wav',
38
+ m4a: 'audio/mp4',
39
+ aac: 'audio/aac',
40
+ flac: 'audio/flac',
41
+ ogg: 'audio/ogg',
42
+ pdf: 'application/pdf',
43
+ doc: 'application/msword',
44
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
45
+ xls: 'application/vnd.ms-excel',
46
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
47
+ zip: 'application/zip',
48
+ };
49
+
20
50
  export const getMimeType = (fileName: string): string => {
21
51
  const ext = fileName.split('.').pop()?.toLowerCase();
22
- const mimeMap: Record<string, string> = {
23
- jpg: 'image/jpeg',
24
- jpeg: 'image/jpeg',
25
- png: 'image/png',
26
- webp: 'image/webp',
27
- heic: 'image/heic',
28
- heif: 'image/heif',
29
- mp4: 'video/mp4',
30
- mov: 'video/quicktime',
31
- avi: 'video/x-msvideo',
32
- mkv: 'video/x-matroska',
33
- webm: 'video/webm',
34
- m4v: 'video/mp4',
35
- flv: 'video/x-flv',
36
- wmv: 'video/x-ms-wmv',
37
- mp3: 'audio/mpeg',
38
- wav: 'audio/wav',
39
- m4a: 'audio/mp4',
40
- aac: 'audio/aac',
41
- flac: 'audio/flac',
42
- ogg: 'audio/ogg',
43
- pdf: 'application/pdf',
44
- doc: 'application/msword',
45
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
46
- xls: 'application/vnd.ms-excel',
47
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
48
- zip: 'application/zip',
49
- };
50
52
  return mimeMap[ext || ''] || 'application/octet-stream';
51
53
  };
52
54
 
55
+ export const getFileIcon = (mimeType?: string): number => {
56
+ if (mimeType?.startsWith('image/')) return Images.ICON_IMG;
57
+
58
+ switch (mimeType) {
59
+ case mimeMap.pdf:
60
+ return Images.ICON_PDF;
61
+
62
+ case mimeMap.doc:
63
+ case mimeMap.docx:
64
+ return Images.ICON_WORD;
65
+
66
+ case mimeMap.xls:
67
+ case mimeMap.xlsx:
68
+ return Images.ICON_EXCEL;
69
+
70
+ default:
71
+ return Images.ICON_TXT;
72
+ }
73
+ };
74
+
53
75
  export const formatFileSize = (bytes: number): string => {
54
76
  if (bytes === 0) return '0 Bytes';
55
77
  const k = 1024;