@droppii-org/chat-mobile 0.2.16 → 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 (127) 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/MergedImageGrid.js +28 -18
  15. package/lib/module/components/MergedImageGrid.js.map +1 -1
  16. package/lib/module/components/messages/fileMessage/index.js +122 -12
  17. package/lib/module/components/messages/fileMessage/index.js.map +1 -1
  18. package/lib/module/components/messages/mergedMessage/index.js +4 -7
  19. package/lib/module/components/messages/mergedMessage/index.js.map +1 -1
  20. package/lib/module/hooks/useFileAttachment.js +116 -0
  21. package/lib/module/hooks/useFileAttachment.js.map +1 -0
  22. package/lib/module/hooks/useFileDownload.js +175 -0
  23. package/lib/module/hooks/useFileDownload.js.map +1 -0
  24. package/lib/module/hooks/useImageAttachment.js +16 -8
  25. package/lib/module/hooks/useImageAttachment.js.map +1 -1
  26. package/lib/module/hooks/useVideoAttachment.js +1 -1
  27. package/lib/module/index.js +1 -1
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/screens/MediaView/VideoPlayer.js +1 -0
  30. package/lib/module/screens/MediaView/VideoPlayer.js.map +1 -1
  31. package/lib/module/screens/MediaView/index.js +7 -9
  32. package/lib/module/screens/MediaView/index.js.map +1 -1
  33. package/lib/module/screens/chat-detail/ChatComposer.js +44 -36
  34. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  35. package/lib/module/screens/chat-detail/ChatListLegend.js +11 -2
  36. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  37. package/lib/module/screens/chat-detail/hooks/useAttachmentMapping.js +51 -0
  38. package/lib/module/screens/chat-detail/hooks/useAttachmentMapping.js.map +1 -0
  39. package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js +39 -13
  40. package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js.map +1 -1
  41. package/lib/module/services/attachmentHandlers/fileAttachmentHandler.js +6 -6
  42. package/lib/module/services/attachmentHandlers/fileAttachmentHandler.js.map +1 -1
  43. package/lib/module/services/attachmentHandlers/index.js +1 -1
  44. package/lib/module/services/attachmentHandlers/index.js.map +1 -1
  45. package/lib/module/services/attachmentHandlers/videoAttachmentHandler.js +1 -1
  46. package/lib/module/translation/resources/i18n.js +10 -0
  47. package/lib/module/translation/resources/i18n.js.map +1 -1
  48. package/lib/module/utils/device.js +32 -0
  49. package/lib/module/utils/device.js.map +1 -1
  50. package/lib/module/utils/imageUtils.js +44 -28
  51. package/lib/module/utils/imageUtils.js.map +1 -1
  52. package/lib/module/utils/videoThumbnail.js +76 -3
  53. package/lib/module/utils/videoThumbnail.js.map +1 -1
  54. package/lib/typescript/src/assets/images/index.d.ts +5 -0
  55. package/lib/typescript/src/assets/images/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/AttachmentPreview/FilePreview.d.ts +6 -0
  57. package/lib/typescript/src/components/AttachmentPreview/FilePreview.d.ts.map +1 -0
  58. package/lib/typescript/src/components/AttachmentPreview/MediaPreview.d.ts +9 -0
  59. package/lib/typescript/src/components/AttachmentPreview/MediaPreview.d.ts.map +1 -0
  60. package/lib/typescript/src/components/AttachmentPreview/index.d.ts +9 -0
  61. package/lib/typescript/src/components/AttachmentPreview/index.d.ts.map +1 -0
  62. package/lib/typescript/src/components/MergedImageGrid.d.ts +2 -2
  63. package/lib/typescript/src/components/MergedImageGrid.d.ts.map +1 -1
  64. package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/messages/mergedMessage/index.d.ts.map +1 -1
  66. package/lib/typescript/src/hooks/useFileAttachment.d.ts +20 -0
  67. package/lib/typescript/src/hooks/useFileAttachment.d.ts.map +1 -0
  68. package/lib/typescript/src/hooks/useFileDownload.d.ts +15 -0
  69. package/lib/typescript/src/hooks/useFileDownload.d.ts.map +1 -0
  70. package/lib/typescript/src/hooks/useImageAttachment.d.ts +1 -2
  71. package/lib/typescript/src/hooks/useImageAttachment.d.ts.map +1 -1
  72. package/lib/typescript/src/screens/MediaView/VideoPlayer.d.ts.map +1 -1
  73. package/lib/typescript/src/screens/MediaView/index.d.ts.map +1 -1
  74. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  75. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  76. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentMapping.d.ts +10 -0
  77. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentMapping.d.ts.map +1 -0
  78. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts +4 -1
  79. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts.map +1 -1
  80. package/lib/typescript/src/services/attachmentHandlers/fileAttachmentHandler.d.ts +2 -2
  81. package/lib/typescript/src/services/attachmentHandlers/fileAttachmentHandler.d.ts.map +1 -1
  82. package/lib/typescript/src/services/attachmentHandlers/index.d.ts +1 -1
  83. package/lib/typescript/src/services/attachmentHandlers/index.d.ts.map +1 -1
  84. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  85. package/lib/typescript/src/types/attachment.d.ts +22 -1
  86. package/lib/typescript/src/types/attachment.d.ts.map +1 -1
  87. package/lib/typescript/src/utils/device.d.ts +7 -0
  88. package/lib/typescript/src/utils/device.d.ts.map +1 -1
  89. package/lib/typescript/src/utils/imageUtils.d.ts +1 -0
  90. package/lib/typescript/src/utils/imageUtils.d.ts.map +1 -1
  91. package/lib/typescript/src/utils/videoThumbnail.d.ts.map +1 -1
  92. package/package.json +5 -1
  93. package/src/assets/images/icon_excel.png +0 -0
  94. package/src/assets/images/icon_img.png +0 -0
  95. package/src/assets/images/icon_pdf.png +0 -0
  96. package/src/assets/images/icon_txt.png +0 -0
  97. package/src/assets/images/icon_word.png +0 -0
  98. package/src/assets/images/index.ts +5 -0
  99. package/src/components/AttachmentPreview/FilePreview.tsx +137 -0
  100. package/src/components/AttachmentPreview/MediaPreview.tsx +238 -0
  101. package/src/components/AttachmentPreview/index.tsx +68 -0
  102. package/src/components/MergedImageGrid.tsx +36 -23
  103. package/src/components/messages/fileMessage/index.tsx +156 -13
  104. package/src/components/messages/mergedMessage/index.tsx +5 -7
  105. package/src/hooks/useFileAttachment.ts +163 -0
  106. package/src/hooks/useFileDownload.ts +200 -0
  107. package/src/hooks/useImageAttachment.ts +23 -13
  108. package/src/hooks/useVideoAttachment.ts +1 -1
  109. package/src/screens/MediaView/VideoPlayer.tsx +1 -0
  110. package/src/screens/MediaView/index.tsx +7 -6
  111. package/src/screens/chat-detail/ChatComposer.tsx +61 -30
  112. package/src/screens/chat-detail/ChatListLegend.tsx +9 -2
  113. package/src/screens/chat-detail/hooks/useAttachmentMapping.ts +58 -0
  114. package/src/screens/chat-detail/hooks/useAttachmentSendHandler.ts +50 -11
  115. package/src/services/attachmentHandlers/fileAttachmentHandler.ts +8 -7
  116. package/src/services/attachmentHandlers/index.ts +4 -1
  117. package/src/services/attachmentHandlers/videoAttachmentHandler.ts +1 -1
  118. package/src/translation/resources/i18n.ts +10 -0
  119. package/src/types/attachment.ts +23 -1
  120. package/src/utils/device.ts +58 -0
  121. package/src/utils/imageUtils.ts +50 -28
  122. package/src/utils/videoThumbnail.ts +77 -3
  123. package/lib/module/components/AttachmentPreview.js +0 -250
  124. package/lib/module/components/AttachmentPreview.js.map +0 -1
  125. package/lib/typescript/src/components/AttachmentPreview.d.ts +0 -28
  126. package/lib/typescript/src/components/AttachmentPreview.d.ts.map +0 -1
  127. package/src/components/AttachmentPreview.tsx +0 -304
@@ -0,0 +1,200 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Platform, Share } from 'react-native';
3
+ import RNFS from 'react-native-fs';
4
+ import FileViewer from 'react-native-file-viewer';
5
+ import UIUtils from '../utils/ui';
6
+ import { trans } from '../translation';
7
+
8
+ export type DownloadStatus = 'idle' | 'downloading' | 'done' | 'error';
9
+
10
+ export interface DownloadState {
11
+ status: DownloadStatus;
12
+ progress: number;
13
+ receivedMB: number;
14
+ totalMB: number;
15
+ localPath: string | null;
16
+ error: string | null;
17
+ }
18
+
19
+ const INITIAL_STATE: DownloadState = {
20
+ status: 'idle',
21
+ progress: 0,
22
+ receivedMB: 0,
23
+ totalMB: 0,
24
+ localPath: null,
25
+ error: null,
26
+ };
27
+
28
+ const CacheDir = RNFS.CachesDirectoryPath;
29
+ const DownloadDir =
30
+ Platform.OS === 'android'
31
+ ? RNFS.DownloadDirectoryPath
32
+ : RNFS.CachesDirectoryPath;
33
+
34
+ export const useFileDownload = (fileName: string, url: string) => {
35
+ const [state, setState] = useState<DownloadState>(INITIAL_STATE);
36
+ const jobIdRef = useRef<number | null>(null);
37
+ const cancelledRef = useRef(false);
38
+
39
+ const openFile = useCallback(async (path: string) => {
40
+ try {
41
+ await FileViewer.open(path, {
42
+ showOpenWithDialog: true,
43
+ showAppsSuggestions: true,
44
+ });
45
+ } catch {
46
+ UIUtils.toast.open({
47
+ title: trans('chat:file_open_failed'),
48
+ theme: 'warning',
49
+ });
50
+ }
51
+ }, []);
52
+
53
+ const runDownload = useCallback(
54
+ async (destDir: string): Promise<string | null> => {
55
+ if (!url || !fileName) return null;
56
+
57
+ const destPath = `${destDir}/${fileName}`;
58
+ cancelledRef.current = false;
59
+ setState({
60
+ status: 'downloading',
61
+ progress: 0,
62
+ receivedMB: 0,
63
+ totalMB: 0,
64
+ localPath: null,
65
+ error: null,
66
+ });
67
+
68
+ const MB = 1024 * 1024;
69
+
70
+ const ret = RNFS.downloadFile({
71
+ fromUrl: url,
72
+ toFile: destPath,
73
+ progress: (res) => {
74
+ if (cancelledRef.current) return;
75
+ const r = res.bytesWritten;
76
+ const t = res.contentLength;
77
+ setState((prev) => ({
78
+ ...prev,
79
+ progress: t > 0 ? Math.round((r / t) * 100) : 0,
80
+ receivedMB: Math.round((r / MB) * 10) / 10,
81
+ totalMB: Math.round((t / MB) * 10) / 10,
82
+ }));
83
+ },
84
+ });
85
+
86
+ jobIdRef.current = ret.jobId;
87
+
88
+ try {
89
+ const result = await ret.promise;
90
+ if (cancelledRef.current) return null;
91
+
92
+ if (result.statusCode < 200 || result.statusCode >= 300) {
93
+ throw new Error(`HTTP ${result.statusCode}`);
94
+ }
95
+
96
+ setState({
97
+ status: 'done',
98
+ progress: 100,
99
+ receivedMB: 0,
100
+ totalMB: 0,
101
+ localPath: destPath,
102
+ error: null,
103
+ });
104
+ return destPath;
105
+ } catch (e: any) {
106
+ if (cancelledRef.current) return null;
107
+ setState({
108
+ status: 'error',
109
+ progress: 0,
110
+ receivedMB: 0,
111
+ totalMB: 0,
112
+ localPath: null,
113
+ error: e?.message ?? trans('chat:file_download_failed'),
114
+ });
115
+ return null;
116
+ }
117
+ },
118
+ [fileName, url]
119
+ );
120
+
121
+ const cancel = useCallback(() => {
122
+ cancelledRef.current = true;
123
+ if (jobIdRef.current !== null) {
124
+ RNFS.stopDownload(jobIdRef.current);
125
+ jobIdRef.current = null;
126
+ }
127
+ setState(INITIAL_STATE);
128
+ }, []);
129
+
130
+ useEffect(() => () => cancel(), [cancel]);
131
+
132
+ // Eye button — open from cache/downloads if available, otherwise download to cache
133
+ const handleView = useCallback(async () => {
134
+ if (state.status === 'downloading') return;
135
+
136
+ if (state.localPath) {
137
+ try {
138
+ const exists = await RNFS.exists(state.localPath);
139
+ if (exists) {
140
+ await openFile(state.localPath);
141
+ return;
142
+ }
143
+ } catch {
144
+ // fall through to search dirs
145
+ }
146
+ }
147
+
148
+ const searchDirs =
149
+ Platform.OS === 'android' ? [DownloadDir, CacheDir] : [CacheDir];
150
+ for (const dir of searchDirs) {
151
+ const dirPath = `${dir}/${fileName}`;
152
+ try {
153
+ const exists = await RNFS.exists(dirPath);
154
+ if (exists) {
155
+ setState({
156
+ status: 'done',
157
+ progress: 100,
158
+ receivedMB: 0,
159
+ totalMB: 0,
160
+ localPath: dirPath,
161
+ error: null,
162
+ });
163
+ await openFile(dirPath);
164
+ return;
165
+ }
166
+ } catch {
167
+ // try next dir
168
+ }
169
+ }
170
+
171
+ const path = await runDownload(CacheDir);
172
+ if (path) openFile(path);
173
+ }, [state.status, state.localPath, fileName, openFile, runDownload]);
174
+
175
+ // Download button — cancel if in progress; otherwise download and expose to Files app.
176
+ // iOS: download to cache then show share sheet (user can tap "Save to Files").
177
+ // Android: download to Downloads dir and trigger media scan.
178
+ const handleDownload = useCallback(async () => {
179
+ if (state.status === 'downloading') {
180
+ cancel();
181
+ return;
182
+ }
183
+ if (Platform.OS === 'ios') {
184
+ const cachedPath = `${CacheDir}/${fileName}`;
185
+ const exists = await RNFS.exists(cachedPath);
186
+ const localPath = exists ? cachedPath : await runDownload(CacheDir);
187
+ if (localPath) {
188
+ await Share.share({ url: `file://${localPath}` });
189
+ }
190
+ } else {
191
+ await runDownload(DownloadDir);
192
+ }
193
+ UIUtils.toast.open({
194
+ title: trans('chat:file_downloaded_success', { fileName }),
195
+ theme: 'info',
196
+ });
197
+ }, [state.status, fileName, runDownload, cancel]);
198
+
199
+ return { state, handleView, handleDownload };
200
+ };
@@ -7,7 +7,7 @@ import { getMimeType, isVideoFromPicker } from '../utils/imageUtils';
7
7
  import { ImageUploadAPI } from '../services/imageUpload';
8
8
  import VideoThumbnailUtil from '../utils/videoThumbnail';
9
9
 
10
- interface ImageWithUploadStatus {
10
+ export interface ImageWithUploadStatus {
11
11
  image: PickedImage;
12
12
  uploadProgress: number;
13
13
  error: string | null;
@@ -237,23 +237,33 @@ export const useImageAttachment = (): UseImageAttachmentReturn => {
237
237
  count: images.length,
238
238
  });
239
239
 
240
- const MAX_FILE_SIZE = 5 * 1024 * 1024;
241
- const oversizedImages = images.filter(
242
- (img) => (img.size || 0) > MAX_FILE_SIZE
243
- );
240
+ // The gallery picker can return both images and videos. Apply the
241
+ // correct size limit per type: 5MB for images, 200MB for videos.
242
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
243
+ const MAX_VIDEO_SIZE = 200 * 1024 * 1024;
244
+ const isWithinLimit = (item: PickedImage) => {
245
+ const maxSize = isVideoFromPicker(item)
246
+ ? MAX_VIDEO_SIZE
247
+ : MAX_IMAGE_SIZE;
248
+ return (item.size || 0) <= maxSize;
249
+ };
244
250
 
245
- if (oversizedImages.length > 0) {
246
- console.warn('ImageAttach', 'Step 5: Some images too large', {
247
- count: oversizedImages.length,
248
- maxSize: MAX_FILE_SIZE,
251
+ const oversizedItems = images.filter((img) => !isWithinLimit(img));
252
+
253
+ if (oversizedItems.length > 0) {
254
+ const hasOversizedVideo = oversizedItems.some(isVideoFromPicker);
255
+ console.warn('ImageAttach', 'Step 5: Some files too large', {
256
+ count: oversizedItems.length,
249
257
  });
250
258
  UIUtils.toast.open({
251
- title: trans('chat:image_too_large'),
259
+ title: trans(
260
+ hasOversizedVideo
261
+ ? 'chat:video_too_large'
262
+ : 'chat:image_too_large'
263
+ ),
252
264
  theme: 'danger',
253
265
  });
254
- const validImages = images.filter(
255
- (img) => (img.size || 0) <= MAX_FILE_SIZE
256
- );
266
+ const validImages = images.filter(isWithinLimit);
257
267
  if (validImages.length === 0) return;
258
268
  images.splice(0, images.length, ...validImages);
259
269
  }
@@ -260,7 +260,7 @@ export const useVideoAttachment = (): UseVideoAttachmentReturn => {
260
260
  count: videos.length,
261
261
  });
262
262
 
263
- const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB for videos
263
+ const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB for videos
264
264
  const oversizedVideos = videos.filter(
265
265
  (vid) => (vid.size || 0) > MAX_FILE_SIZE
266
266
  );
@@ -140,6 +140,7 @@ const VideoPlayer = memo((props: Props) => {
140
140
  <KContainer.View
141
141
  style={[styles.videoContainer, memoHeight]}
142
142
  collapsable={false}
143
+ edges={['bottom']}
143
144
  onLayout={handleContainerLayout}
144
145
  >
145
146
  {renderIndicator()}
@@ -7,6 +7,7 @@ import {
7
7
  FlatList,
8
8
  type ListRenderItemInfo,
9
9
  type ViewToken,
10
+ StatusBar,
10
11
  } from 'react-native';
11
12
 
12
13
  import LinearGradient from 'react-native-linear-gradient';
@@ -58,12 +59,12 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
58
59
 
59
60
  const onDownload = useCallback(() => {}, []);
60
61
 
61
- // useEffect(() => {
62
- // StatusBar.setHidden(true);
63
- // return () => {
64
- // StatusBar.setHidden(false);
65
- // };
66
- // }, []);
62
+ useEffect(() => {
63
+ StatusBar.setHidden(true);
64
+ return () => {
65
+ StatusBar.setHidden(false);
66
+ };
67
+ }, []);
67
68
 
68
69
  useEffect(() => {
69
70
  if (Platform.OS === 'android') {
@@ -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
@@ -191,12 +191,19 @@ export const ChatListLegend = memo(
191
191
  on: {
192
192
  dataChange: !isLoadingEarlier,
193
193
  layout: !isLoadingEarlier,
194
- itemLayout: true,
194
+ // Must be disabled while prepending older history; otherwise re-measuring
195
+ // the freshly prepended items snaps the list back to the bottom.
196
+ itemLayout: !isLoadingEarlier,
195
197
  },
196
198
  }),
197
199
  [isLoadingEarlier]
198
200
  );
199
201
 
202
+ // Stable config so the native maintainVisibleContentPosition prop isn't toggled
203
+ // mid-prepend (toggling forces an extra layout recompute). `data` anchoring keeps
204
+ // the viewport pinned to the item being read when older messages are added on top.
205
+ const maintainVisiblePositionConfig = useMemo(() => ({ data: true }), []);
206
+
200
207
  const handleStartReached = useCallback(() => {
201
208
  if (__DEV__) {
202
209
  console.log('[LegendList] onStartReached triggered', {
@@ -349,7 +356,7 @@ export const ChatListLegend = memo(
349
356
  : undefined
350
357
  }
351
358
  alignItemsAtEnd
352
- maintainVisibleContentPosition={!isLoadingEarlier}
359
+ maintainVisibleContentPosition={maintainVisiblePositionConfig}
353
360
  maintainScrollAtEnd={maintainScrollConfig}
354
361
  scrollEventThrottle={16}
355
362
  onScroll={handleScroll}
@@ -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,