@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,68 @@
1
+ import { memo, useCallback } from 'react';
2
+ import { ScrollView, StyleSheet } from 'react-native';
3
+ import type { AttachmentItem, AttachmentType } from '../../types/attachment';
4
+ import { MediaPreview } from './MediaPreview';
5
+ import { FilePreview } from './FilePreview';
6
+
7
+ interface AttachmentPreviewProps {
8
+ visible: boolean;
9
+ attachments?: Array<AttachmentItem>;
10
+ onRemove: (type: AttachmentType, data: unknown) => void;
11
+ }
12
+
13
+ export const AttachmentPreview = memo(
14
+ ({ visible, attachments, onRemove }: AttachmentPreviewProps) => {
15
+ const onFileRemove = useCallback(
16
+ (id: string) => {
17
+ onRemove('file', id);
18
+ },
19
+ [onRemove]
20
+ );
21
+
22
+ if (!visible || !attachments?.length) {
23
+ return null;
24
+ }
25
+
26
+ return (
27
+ <ScrollView
28
+ horizontal
29
+ showsHorizontalScrollIndicator={false}
30
+ scrollEventThrottle={16}
31
+ contentContainerStyle={styles.scrollContent}
32
+ >
33
+ {attachments?.map((item, index) => {
34
+ switch (item.type) {
35
+ case 'file':
36
+ return (
37
+ <FilePreview
38
+ key={`${item.type}-${index}`}
39
+ file={item}
40
+ onRemove={onFileRemove}
41
+ />
42
+ );
43
+
44
+ default:
45
+ return (
46
+ <MediaPreview
47
+ key={`${item.type}-${index}`}
48
+ index={index}
49
+ item={item}
50
+ onRemove={onRemove}
51
+ />
52
+ );
53
+ }
54
+ })}
55
+ </ScrollView>
56
+ );
57
+ }
58
+ );
59
+
60
+ AttachmentPreview.displayName = 'AttachmentPreview';
61
+
62
+ const styles = StyleSheet.create({
63
+ scrollContent: {
64
+ padding: 8,
65
+ paddingTop: 12,
66
+ gap: 8,
67
+ },
68
+ });
@@ -1,6 +1,12 @@
1
1
  import { memo, useMemo, useState, useCallback } from 'react';
2
2
  import { StyleSheet, useWindowDimensions } from 'react-native';
3
- import { KContainer, KImage, KLabel, KColors } from '@droppii/libs';
3
+ import {
4
+ KContainer,
5
+ KImage,
6
+ KLabel,
7
+ KColors,
8
+ KSpacingValue,
9
+ } from '@droppii/libs';
4
10
  import ChatImageDimens from '../utils/chatImageDimens';
5
11
  import type { MediaItem } from '../hooks/useMediaViewer';
6
12
 
@@ -14,11 +20,11 @@ interface MergedImageGridProps {
14
20
 
15
21
  /**
16
22
  * Generic image grid for merged messages
17
- * Layout: 1 image (1x1), 2 images (1x2), 3 images (1x3), 4+ images (2x2)
18
- * Shows "+N more" overlay on last cell if images exceed limit
23
+ * Layout: always a single horizontal row (1xN), N capped at maxDisplay (default 3)
24
+ * Shows "+N more" overlay on last cell if images exceed limit (e.g. 4 images → 3 + "+1")
19
25
  */
20
26
  export const MergedImageGrid = memo(
21
- ({ images, maxDisplay = 4, onMediaPress }: MergedImageGridProps) => {
27
+ ({ images, maxDisplay = 3, onMediaPress }: MergedImageGridProps) => {
22
28
  const windowWidth = useWindowDimensions().width;
23
29
  const [loadingState, setLoadingState] = useState<Record<number, boolean>>(
24
30
  {}
@@ -106,26 +112,33 @@ export const MergedImageGrid = memo(
106
112
  return null;
107
113
  }
108
114
 
109
- // Calculate columns based on image count
110
- let columns = 1;
111
- if (displayedImages.length === 2) {
112
- columns = 2; // 2 images: horizontal layout (1x2)
113
- } else if (displayedImages.length === 3) {
114
- columns = 3; // 3 images: horizontal layout (1x3)
115
- } else if (displayedImages.length >= 4) {
116
- columns = 2; // 4+ images: grid layout (2x2)
117
- }
115
+ // Always lay out images in a single horizontal row (1xN)
116
+ const columns = displayedImages.length;
118
117
 
119
- // Use calculated aspect ratio for single image, 100x100 for multiple images
120
- const cellSize =
121
- displayedImages.length === 1 && singleImageSize
122
- ? singleImageSize.width
123
- : 100;
124
- const cellHeight =
125
- displayedImages.length === 1 && singleImageSize
126
- ? singleImageSize.height
127
- : cellSize;
128
118
  const gap = 4;
119
+
120
+ // Mirror the parent layout chain so the row never exceeds its container:
121
+ // messageRow: width 100% with 0.75rem horizontal padding (both sides)
122
+ // bubble wrapper: maxWidth 80% of that
123
+ // bubble: another 0.75rem horizontal padding (both sides)
124
+ const rowPaddingH = KSpacingValue['0.75rem'] * 2;
125
+ const bubblePaddingH = KSpacingValue['0.75rem'] * 2;
126
+ const availableWidth = (windowWidth - rowPaddingH) * 0.8 - bubblePaddingH;
127
+
128
+ // Single image keeps its aspect-ratio size; multiple images share the row
129
+ // width so the whole row fits inside the bubble (cell capped at 100px).
130
+ let cellSize: number;
131
+ let cellHeight: number;
132
+ if (displayedImages.length === 1 && singleImageSize) {
133
+ cellSize = singleImageSize.width;
134
+ cellHeight = singleImageSize.height;
135
+ } else {
136
+ const fitted = Math.floor(
137
+ (availableWidth - gap * (columns - 1)) / columns
138
+ );
139
+ cellSize = Math.min(100, fitted);
140
+ cellHeight = cellSize;
141
+ }
129
142
  const totalWidth = cellSize * columns + gap * (columns - 1);
130
143
 
131
144
  return (
@@ -203,7 +216,7 @@ MergedImageGrid.displayName = 'MergedImageGrid';
203
216
  const styles = StyleSheet.create({
204
217
  grid: {
205
218
  flexDirection: 'row',
206
- flexWrap: 'wrap',
219
+ flexWrap: 'nowrap',
207
220
  gap: 4,
208
221
  justifyContent: 'flex-start',
209
222
  alignItems: 'flex-start',
@@ -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
+ });
@@ -28,13 +28,11 @@ export const MergedMessage = memo(
28
28
  ]}
29
29
  >
30
30
  {imageMessages.length > 0 && (
31
- <KContainer.View marginB="0.5rem">
32
- <MergedImageGrid
33
- images={imageMessages}
34
- maxDisplay={4}
35
- onMediaPress={onMediaPress}
36
- />
37
- </KContainer.View>
31
+ <MergedImageGrid
32
+ images={imageMessages}
33
+ maxDisplay={3}
34
+ onMediaPress={onMediaPress}
35
+ />
38
36
  )}
39
37
  </KContainer.View>
40
38
  </KContainer.View>
@@ -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
+ };