@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.
- package/lib/module/assets/images/icon_excel.png +0 -0
- package/lib/module/assets/images/icon_img.png +0 -0
- package/lib/module/assets/images/icon_pdf.png +0 -0
- package/lib/module/assets/images/icon_txt.png +0 -0
- package/lib/module/assets/images/icon_word.png +0 -0
- package/lib/module/assets/images/index.js +5 -0
- package/lib/module/assets/images/index.js.map +1 -1
- package/lib/module/components/AttachmentPreview/FilePreview.js +107 -0
- package/lib/module/components/AttachmentPreview/FilePreview.js.map +1 -0
- package/lib/module/components/AttachmentPreview/MediaPreview.js +212 -0
- package/lib/module/components/AttachmentPreview/MediaPreview.js.map +1 -0
- package/lib/module/components/AttachmentPreview/index.js +49 -0
- package/lib/module/components/AttachmentPreview/index.js.map +1 -0
- package/lib/module/components/MergedImageGrid.js +28 -18
- package/lib/module/components/MergedImageGrid.js.map +1 -1
- package/lib/module/components/messages/fileMessage/index.js +122 -12
- package/lib/module/components/messages/fileMessage/index.js.map +1 -1
- package/lib/module/components/messages/mergedMessage/index.js +4 -7
- package/lib/module/components/messages/mergedMessage/index.js.map +1 -1
- package/lib/module/hooks/useFileAttachment.js +116 -0
- package/lib/module/hooks/useFileAttachment.js.map +1 -0
- package/lib/module/hooks/useFileDownload.js +175 -0
- package/lib/module/hooks/useFileDownload.js.map +1 -0
- package/lib/module/hooks/useImageAttachment.js +16 -8
- package/lib/module/hooks/useImageAttachment.js.map +1 -1
- package/lib/module/hooks/useVideoAttachment.js +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/screens/MediaView/VideoPlayer.js +1 -0
- package/lib/module/screens/MediaView/VideoPlayer.js.map +1 -1
- package/lib/module/screens/MediaView/index.js +7 -9
- package/lib/module/screens/MediaView/index.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatComposer.js +44 -36
- package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js +11 -2
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/hooks/useAttachmentMapping.js +51 -0
- package/lib/module/screens/chat-detail/hooks/useAttachmentMapping.js.map +1 -0
- package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js +39 -13
- package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js.map +1 -1
- package/lib/module/services/attachmentHandlers/fileAttachmentHandler.js +6 -6
- package/lib/module/services/attachmentHandlers/fileAttachmentHandler.js.map +1 -1
- package/lib/module/services/attachmentHandlers/index.js +1 -1
- package/lib/module/services/attachmentHandlers/index.js.map +1 -1
- package/lib/module/services/attachmentHandlers/videoAttachmentHandler.js +1 -1
- package/lib/module/translation/resources/i18n.js +10 -0
- package/lib/module/translation/resources/i18n.js.map +1 -1
- package/lib/module/utils/device.js +32 -0
- package/lib/module/utils/device.js.map +1 -1
- package/lib/module/utils/imageUtils.js +44 -28
- package/lib/module/utils/imageUtils.js.map +1 -1
- package/lib/module/utils/videoThumbnail.js +76 -3
- package/lib/module/utils/videoThumbnail.js.map +1 -1
- package/lib/typescript/src/assets/images/index.d.ts +5 -0
- package/lib/typescript/src/assets/images/index.d.ts.map +1 -1
- package/lib/typescript/src/components/AttachmentPreview/FilePreview.d.ts +6 -0
- package/lib/typescript/src/components/AttachmentPreview/FilePreview.d.ts.map +1 -0
- package/lib/typescript/src/components/AttachmentPreview/MediaPreview.d.ts +9 -0
- package/lib/typescript/src/components/AttachmentPreview/MediaPreview.d.ts.map +1 -0
- package/lib/typescript/src/components/AttachmentPreview/index.d.ts +9 -0
- package/lib/typescript/src/components/AttachmentPreview/index.d.ts.map +1 -0
- package/lib/typescript/src/components/MergedImageGrid.d.ts +2 -2
- package/lib/typescript/src/components/MergedImageGrid.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/mergedMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useFileAttachment.d.ts +20 -0
- package/lib/typescript/src/hooks/useFileAttachment.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useFileDownload.d.ts +15 -0
- package/lib/typescript/src/hooks/useFileDownload.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useImageAttachment.d.ts +1 -2
- package/lib/typescript/src/hooks/useImageAttachment.d.ts.map +1 -1
- package/lib/typescript/src/screens/MediaView/VideoPlayer.d.ts.map +1 -1
- package/lib/typescript/src/screens/MediaView/index.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentMapping.d.ts +10 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentMapping.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts +4 -1
- package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts.map +1 -1
- package/lib/typescript/src/services/attachmentHandlers/fileAttachmentHandler.d.ts +2 -2
- package/lib/typescript/src/services/attachmentHandlers/fileAttachmentHandler.d.ts.map +1 -1
- package/lib/typescript/src/services/attachmentHandlers/index.d.ts +1 -1
- package/lib/typescript/src/services/attachmentHandlers/index.d.ts.map +1 -1
- package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
- package/lib/typescript/src/types/attachment.d.ts +22 -1
- package/lib/typescript/src/types/attachment.d.ts.map +1 -1
- package/lib/typescript/src/utils/device.d.ts +7 -0
- package/lib/typescript/src/utils/device.d.ts.map +1 -1
- package/lib/typescript/src/utils/imageUtils.d.ts +1 -0
- package/lib/typescript/src/utils/imageUtils.d.ts.map +1 -1
- package/lib/typescript/src/utils/videoThumbnail.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/assets/images/icon_excel.png +0 -0
- package/src/assets/images/icon_img.png +0 -0
- package/src/assets/images/icon_pdf.png +0 -0
- package/src/assets/images/icon_txt.png +0 -0
- package/src/assets/images/icon_word.png +0 -0
- package/src/assets/images/index.ts +5 -0
- package/src/components/AttachmentPreview/FilePreview.tsx +137 -0
- package/src/components/AttachmentPreview/MediaPreview.tsx +238 -0
- package/src/components/AttachmentPreview/index.tsx +68 -0
- package/src/components/MergedImageGrid.tsx +36 -23
- package/src/components/messages/fileMessage/index.tsx +156 -13
- package/src/components/messages/mergedMessage/index.tsx +5 -7
- package/src/hooks/useFileAttachment.ts +163 -0
- package/src/hooks/useFileDownload.ts +200 -0
- package/src/hooks/useImageAttachment.ts +23 -13
- package/src/hooks/useVideoAttachment.ts +1 -1
- package/src/screens/MediaView/VideoPlayer.tsx +1 -0
- package/src/screens/MediaView/index.tsx +7 -6
- package/src/screens/chat-detail/ChatComposer.tsx +61 -30
- package/src/screens/chat-detail/ChatListLegend.tsx +9 -2
- package/src/screens/chat-detail/hooks/useAttachmentMapping.ts +58 -0
- package/src/screens/chat-detail/hooks/useAttachmentSendHandler.ts +50 -11
- package/src/services/attachmentHandlers/fileAttachmentHandler.ts +8 -7
- package/src/services/attachmentHandlers/index.ts +4 -1
- package/src/services/attachmentHandlers/videoAttachmentHandler.ts +1 -1
- package/src/translation/resources/i18n.ts +10 -0
- package/src/types/attachment.ts +23 -1
- package/src/utils/device.ts +58 -0
- package/src/utils/imageUtils.ts +50 -28
- package/src/utils/videoThumbnail.ts +77 -3
- package/lib/module/components/AttachmentPreview.js +0 -250
- package/lib/module/components/AttachmentPreview.js.map +0 -1
- package/lib/typescript/src/components/AttachmentPreview.d.ts +0 -28
- package/lib/typescript/src/components/AttachmentPreview.d.ts.map +0 -1
- 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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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(
|
|
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 =
|
|
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
|
);
|
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
() =>
|
|
141
|
-
|
|
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 =
|
|
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
|
-
|
|
252
|
+
const handleImageAttachRef = useRef(handleImageAttach);
|
|
253
|
+
handleImageAttachRef.current = handleImageAttach;
|
|
236
254
|
const debouncedImagePickerRef = useRef(
|
|
237
|
-
debounce(
|
|
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={
|
|
302
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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,
|