@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
@@ -1,26 +1,145 @@
1
- import { memo } from 'react';
2
- import { KContainer, KLabel } from '@droppii/libs';
1
+ import { memo, useMemo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { KContainer, KImage, KLabel, KColors } from '@droppii/libs';
3
4
  import { CHAT_BUBBLE_COLORS } from '../../../screens/chat-detail/constants';
4
5
  import { messageStyles } from '../styles';
5
6
  import type { BaseLegendMessageProps } from '../types';
7
+ import { useFileDownload } from '../../../hooks/useFileDownload';
8
+ import {
9
+ getFileIcon,
10
+ getMimeType,
11
+ formatFileSize,
12
+ } from '../../../utils/imageUtils';
13
+ import { trans } from '../../../translation';
6
14
 
7
15
  export const FileMessage = memo(
8
16
  ({ message, isOutgoing }: BaseLegendMessageProps) => {
9
- const fileName = message.fileElem?.fileName || message.content || '[File]';
17
+ const fileName = message.fileElem?.fileName || '[File]';
18
+ const fileSize = message.fileElem?.fileSize ?? 0;
19
+ const url =
20
+ (message.fileElem as any)?.sourceUrl ||
21
+ (message.fileElem as any)?.url ||
22
+ '';
23
+
24
+ const { state, handleView, handleDownload } = useFileDownload(
25
+ fileName,
26
+ url
27
+ );
28
+
29
+ const mimeType = useMemo(() => getMimeType(fileName), [fileName]);
30
+ const iconSource = useMemo(() => getFileIcon(mimeType), [mimeType]);
31
+
32
+ const subtitle = useMemo(() => {
33
+ if (state.status === 'downloading') {
34
+ return trans('chat:file_downloading', {
35
+ received: state.receivedMB ?? 0,
36
+ total: state.totalMB ?? 0,
37
+ });
38
+ }
39
+ if (state.status === 'error') {
40
+ return state.error ?? trans('chat:file_download_failed');
41
+ }
42
+ return formatFileSize(fileSize);
43
+ }, [state, fileSize]);
44
+
45
+ const subtitleColor =
46
+ state.status === 'error'
47
+ ? (KColors.palette.danger?.w400 ?? '#FF3B30')
48
+ : KColors.palette.gray.w500;
49
+
50
+ const isDownloading = state.status === 'downloading';
10
51
 
11
52
  return (
12
- <KContainer.View style={messageStyles.wrapper}>
53
+ <KContainer.View
54
+ padding={'0.5rem'}
55
+ style={[
56
+ styles.wrapper,
57
+ messageStyles.wrapper,
58
+ isOutgoing ? messageStyles.bubbleSent : messageStyles.bubbleReceived,
59
+ ]}
60
+ >
13
61
  <KContainer.View
14
- style={[
15
- messageStyles.bubble,
16
- isOutgoing
17
- ? messageStyles.bubbleSent
18
- : messageStyles.bubbleReceived,
19
- ]}
62
+ row
63
+ alignItems
64
+ br={'3x'}
65
+ paddingR={'0.75rem'}
66
+ paddingV={'0.25rem'}
67
+ background={KColors.white}
20
68
  >
21
- <KLabel.Text typo="TextSmNormal" color={CHAT_BUBBLE_COLORS.text}>
22
- 📎 {fileName}
23
- </KLabel.Text>
69
+ <KContainer.VisibleView visible={isDownloading}>
70
+ <KContainer.Touchable
71
+ center
72
+ br="round"
73
+ style={styles.cancelBtn}
74
+ width={32}
75
+ height={32}
76
+ onPress={handleDownload}
77
+ >
78
+ <KContainer.View
79
+ center
80
+ br="round"
81
+ style={styles.cancelBtnIconContainer}
82
+ width={26}
83
+ height={26}
84
+ >
85
+ <KImage.VectorIcons
86
+ name="close"
87
+ provider="MaterialCommunityIcons"
88
+ size={24}
89
+ color={KColors.white}
90
+ />
91
+ </KContainer.View>
92
+ </KContainer.Touchable>
93
+ </KContainer.VisibleView>
94
+ <KContainer.VisibleView visible={!isDownloading}>
95
+ <KImage.Base
96
+ uri={iconSource}
97
+ style={styles.fileIcon}
98
+ resizeMode="contain"
99
+ />
100
+ </KContainer.VisibleView>
101
+
102
+ {/* File name + size/progress */}
103
+ <KContainer.View flexS={1}>
104
+ <KLabel.Text
105
+ typo="TextSmMedium"
106
+ color={CHAT_BUBBLE_COLORS.text}
107
+ numberOfLines={1}
108
+ ellipsizeMode="middle"
109
+ >
110
+ {fileName}
111
+ </KLabel.Text>
112
+ <KLabel.Text
113
+ typo="TextXsNormal"
114
+ color={subtitleColor}
115
+ numberOfLines={1}
116
+ >
117
+ {subtitle}
118
+ </KLabel.Text>
119
+ </KContainer.View>
120
+
121
+ {/* Eye (view) button */}
122
+ <KImage.VectorIcons
123
+ name="eye-outline"
124
+ provider="MaterialCommunityIcons"
125
+ onPress={handleView}
126
+ size={20}
127
+ color={KColors.palette.gray.w600}
128
+ containerStyle={styles.viewIcon}
129
+ />
130
+
131
+ <KImage.VectorIcons
132
+ name={'download-o'}
133
+ provider="DroppiiNew"
134
+ onPress={isDownloading ? undefined : handleDownload}
135
+ size={20}
136
+ color={
137
+ state.status === 'error'
138
+ ? (KColors.palette.danger?.w400 ?? '#FF3B30')
139
+ : KColors.palette.gray.w600
140
+ }
141
+ containerStyle={isDownloading ? styles.disabledIcon : undefined}
142
+ />
24
143
  </KContainer.View>
25
144
  </KContainer.View>
26
145
  );
@@ -28,3 +147,27 @@ export const FileMessage = memo(
28
147
  );
29
148
 
30
149
  FileMessage.displayName = 'FileMessage';
150
+
151
+ const styles = StyleSheet.create({
152
+ wrapper: {
153
+ borderRadius: 20,
154
+ },
155
+ fileIcon: {
156
+ width: 24,
157
+ height: 24,
158
+ marginHorizontal: 12,
159
+ },
160
+ cancelBtn: {
161
+ backgroundColor: KColors.hexToRgba('#6355CA', 0.3),
162
+ marginHorizontal: 6,
163
+ },
164
+ cancelBtnIconContainer: {
165
+ backgroundColor: '#6355CA',
166
+ },
167
+ viewIcon: {
168
+ marginHorizontal: 12,
169
+ },
170
+ disabledIcon: {
171
+ opacity: 0.3,
172
+ },
173
+ });
@@ -0,0 +1,163 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { ImageUploadAPI } from '../services/imageUpload';
3
+ import DeviceUtils from '../utils/device';
4
+ import UIUtils from '../utils/ui';
5
+ import { trans } from '../translation';
6
+ import { MAX_FILE_SIZE } from '../services/attachmentHandlers';
7
+
8
+ const MAX_FILE_COUNT = 3;
9
+
10
+ export interface FileWithUploadStatus {
11
+ id: string;
12
+ uri: string;
13
+ name: string;
14
+ size: number;
15
+ mimeType: string;
16
+ uploadProgress: number;
17
+ error: string | null;
18
+ isUploading: boolean;
19
+ uploadedUrl?: string;
20
+ }
21
+
22
+ export interface UseFileAttachmentReturn {
23
+ selectedFiles: FileWithUploadStatus[];
24
+ isFilePreviewVisible: boolean;
25
+ handleFileAttach: () => Promise<void>;
26
+ handleFileRemove: (id: string) => void;
27
+ clearAllFiles: () => void;
28
+ }
29
+
30
+ export const useFileAttachment = (): UseFileAttachmentReturn => {
31
+ const [selectedFiles, setSelectedFiles] = useState<FileWithUploadStatus[]>(
32
+ []
33
+ );
34
+
35
+ const updateFileStatus = useCallback(
36
+ (id: string, updates: Partial<Omit<FileWithUploadStatus, 'id'>>) => {
37
+ setSelectedFiles((prev) =>
38
+ prev.map((f) => (f.id === id ? { ...f, ...updates } : f))
39
+ );
40
+ },
41
+ []
42
+ );
43
+
44
+ const uploadFile = useCallback(
45
+ async (file: FileWithUploadStatus) => {
46
+ try {
47
+ updateFileStatus(file.id, {
48
+ isUploading: true,
49
+ error: null,
50
+ uploadProgress: 0,
51
+ });
52
+
53
+ const uploaded = await ImageUploadAPI.uploadImageFile(
54
+ {
55
+ path: file.uri.startsWith('file://') ? file.uri.slice(7) : file.uri,
56
+ filename: file.name,
57
+ size: file.size,
58
+ width: 0,
59
+ height: 0,
60
+ mime: file.mimeType,
61
+ },
62
+ (progress) => updateFileStatus(file.id, { uploadProgress: progress })
63
+ );
64
+
65
+ if (!uploaded?.url) throw new Error('No URL returned from server');
66
+
67
+ updateFileStatus(file.id, {
68
+ isUploading: false,
69
+ uploadProgress: 100,
70
+ uploadedUrl: uploaded.url,
71
+ });
72
+ } catch (error: any) {
73
+ // TODO: handle upload failure
74
+ console.error('[FileAttach] Upload failed:', error);
75
+ updateFileStatus(file.id, {
76
+ isUploading: false,
77
+ error: error?.message ?? trans('chat:file_upload_failed'),
78
+ });
79
+ }
80
+ },
81
+ [updateFileStatus]
82
+ );
83
+
84
+ const handleFileAttach = useCallback(async () => {
85
+ try {
86
+ const currentCount = selectedFiles.length;
87
+ if (currentCount >= MAX_FILE_COUNT) {
88
+ UIUtils.toast.open({
89
+ title: trans('chat:file_max_count'),
90
+ theme: 'danger',
91
+ });
92
+ return;
93
+ }
94
+
95
+ const picked = await DeviceUtils.openFilePicker(true);
96
+ if (!picked || picked.length === 0) return;
97
+
98
+ const oversized = picked.filter((f) => f.size > MAX_FILE_SIZE);
99
+ let candidates = picked;
100
+ if (oversized.length > 0) {
101
+ UIUtils.toast.open({
102
+ title: trans('chat:file_too_large'),
103
+ theme: 'danger',
104
+ });
105
+ candidates = picked.filter((f) => f.size <= MAX_FILE_SIZE);
106
+ if (candidates.length === 0) return;
107
+ }
108
+
109
+ const deduplicated = candidates.filter(
110
+ (f) =>
111
+ !selectedFiles.some((s) => s.name === f.name && s.size === f.size)
112
+ );
113
+ if (deduplicated.length === 0) return;
114
+
115
+ const remaining = MAX_FILE_COUNT - currentCount;
116
+ const toAdd = deduplicated.slice(0, remaining);
117
+
118
+ if (toAdd.length < deduplicated.length) {
119
+ UIUtils.toast.open({
120
+ title: trans('chat:file_max_count'),
121
+ theme: 'danger',
122
+ });
123
+ }
124
+
125
+ const newFiles: FileWithUploadStatus[] = toAdd.map((f) => ({
126
+ id: `file-${Date.now()}-${Math.random().toString(36).slice(2)}`,
127
+ uri: f.uri,
128
+ name: f.name,
129
+ size: f.size,
130
+ mimeType: f.mimeType,
131
+ uploadProgress: 0,
132
+ error: null,
133
+ isUploading: true,
134
+ }));
135
+
136
+ setSelectedFiles((prev) => [...prev, ...newFiles]);
137
+
138
+ await Promise.all(newFiles.map((f) => uploadFile(f)));
139
+ } catch (error: any) {
140
+ UIUtils.toast.open({
141
+ title: trans('chat:file_pick_failed'),
142
+ message: error instanceof Error ? error.message : 'Unknown error',
143
+ theme: 'danger',
144
+ });
145
+ }
146
+ }, [selectedFiles, uploadFile]);
147
+
148
+ const handleFileRemove = useCallback((id: string) => {
149
+ setSelectedFiles((prev) => prev.filter((f) => f.id !== id));
150
+ }, []);
151
+
152
+ const clearAllFiles = useCallback(() => {
153
+ setSelectedFiles([]);
154
+ }, []);
155
+
156
+ return {
157
+ selectedFiles,
158
+ isFilePreviewVisible: selectedFiles.length > 0,
159
+ handleFileAttach,
160
+ handleFileRemove,
161
+ clearAllFiles,
162
+ };
163
+ };
@@ -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;