@droppii-org/chat-mobile 0.2.14 → 0.2.16

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 (75) hide show
  1. package/lib/module/components/ThreadCard/ThreadCard.js +2 -1
  2. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  3. package/lib/module/components/messages/imageMessage/index.js.map +1 -1
  4. package/lib/module/hooks/useImageAttachment.js +34 -63
  5. package/lib/module/hooks/useImageAttachment.js.map +1 -1
  6. package/lib/module/hooks/useLinkPreview/useFetchUrlMetadata.js +3 -2
  7. package/lib/module/hooks/useLinkPreview/useFetchUrlMetadata.js.map +1 -1
  8. package/lib/module/index.js +1 -1
  9. package/lib/module/index.js.map +1 -1
  10. package/lib/module/screens/MediaView/index.js +1 -1
  11. package/lib/module/screens/chat-detail/ChatComposer.js +10 -3
  12. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  13. package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js +66 -25
  14. package/lib/module/screens/chat-detail/hooks/useAttachmentSendHandler.js.map +1 -1
  15. package/lib/module/services/attachmentHandlers/imageAttachmentHandler.js +6 -3
  16. package/lib/module/services/attachmentHandlers/imageAttachmentHandler.js.map +1 -1
  17. package/lib/module/services/auth.js +69 -0
  18. package/lib/module/services/auth.js.map +1 -1
  19. package/lib/module/services/imageUpload.js +84 -58
  20. package/lib/module/services/imageUpload.js.map +1 -1
  21. package/lib/module/utils/chatImageDimens.js +18 -1
  22. package/lib/module/utils/chatImageDimens.js.map +1 -1
  23. package/lib/module/utils/dateTimeUtils.js +44 -0
  24. package/lib/module/utils/dateTimeUtils.js.map +1 -0
  25. package/lib/module/utils/device.js +2 -1
  26. package/lib/module/utils/device.js.map +1 -1
  27. package/lib/module/utils/imageUtils.js +4 -2
  28. package/lib/module/utils/imageUtils.js.map +1 -1
  29. package/lib/module/{components/ThreadCard/thread-card.utils.js → utils/messageUtils.js} +18 -42
  30. package/lib/module/utils/messageUtils.js.map +1 -0
  31. package/lib/module/utils/videoThumbnail.js +11 -5
  32. package/lib/module/utils/videoThumbnail.js.map +1 -1
  33. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  34. package/lib/typescript/src/components/messages/imageMessage/index.d.ts.map +1 -1
  35. package/lib/typescript/src/hooks/useImageAttachment.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useLinkPreview/useFetchUrlMetadata.d.ts.map +1 -1
  37. package/lib/typescript/src/index.d.ts +1 -1
  38. package/lib/typescript/src/index.d.ts.map +1 -1
  39. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  40. package/lib/typescript/src/screens/chat-detail/hooks/useAttachmentSendHandler.d.ts.map +1 -1
  41. package/lib/typescript/src/services/attachmentHandlers/imageAttachmentHandler.d.ts.map +1 -1
  42. package/lib/typescript/src/services/auth.d.ts +32 -0
  43. package/lib/typescript/src/services/auth.d.ts.map +1 -1
  44. package/lib/typescript/src/services/imageUpload.d.ts.map +1 -1
  45. package/lib/typescript/src/utils/chatImageDimens.d.ts +1 -0
  46. package/lib/typescript/src/utils/chatImageDimens.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/dateTimeUtils.d.ts +6 -0
  48. package/lib/typescript/src/utils/dateTimeUtils.d.ts.map +1 -0
  49. package/lib/typescript/src/utils/device.d.ts.map +1 -1
  50. package/lib/typescript/src/utils/imageUtils.d.ts.map +1 -1
  51. package/lib/typescript/src/utils/messageUtils.d.ts +8 -0
  52. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -0
  53. package/lib/typescript/src/utils/videoThumbnail.d.ts +1 -0
  54. package/lib/typescript/src/utils/videoThumbnail.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/components/ThreadCard/ThreadCard.tsx +2 -1
  57. package/src/components/messages/imageMessage/index.tsx +0 -1
  58. package/src/hooks/useImageAttachment.ts +45 -70
  59. package/src/hooks/useLinkPreview/useFetchUrlMetadata.ts +3 -2
  60. package/src/index.tsx +8 -1
  61. package/src/screens/MediaView/index.tsx +1 -1
  62. package/src/screens/chat-detail/ChatComposer.tsx +9 -3
  63. package/src/screens/chat-detail/hooks/useAttachmentSendHandler.ts +81 -25
  64. package/src/services/attachmentHandlers/imageAttachmentHandler.ts +11 -1
  65. package/src/services/auth.ts +77 -0
  66. package/src/services/imageUpload.ts +116 -73
  67. package/src/utils/chatImageDimens.ts +21 -1
  68. package/src/utils/dateTimeUtils.ts +48 -0
  69. package/src/utils/device.ts +1 -0
  70. package/src/utils/imageUtils.ts +10 -2
  71. package/src/{components/ThreadCard/thread-card.utils.ts → utils/messageUtils.ts} +18 -56
  72. package/src/utils/videoThumbnail.ts +14 -3
  73. package/lib/module/components/ThreadCard/thread-card.utils.js.map +0 -1
  74. package/lib/typescript/src/components/ThreadCard/thread-card.utils.d.ts +0 -4
  75. package/lib/typescript/src/components/ThreadCard/thread-card.utils.d.ts.map +0 -1
@@ -1,5 +1,6 @@
1
- import { memo, useCallback, useEffect, useMemo } from 'react';
1
+ import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
2
2
  import { Platform, StyleSheet } from 'react-native';
3
+ import { debounce } from 'lodash';
3
4
  import Reanimated from 'react-native-reanimated';
4
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { KContainer, KColors, KImage, KButton } from '@droppii/libs';
@@ -231,16 +232,21 @@ export const ChatComposer = memo(
231
232
  }
232
233
  }, [value, setIsInputWrapped]);
233
234
 
235
+ // Debounced image picker (prevent rapid clicks)
236
+ const debouncedImagePickerRef = useRef(
237
+ debounce(handleImageAttach, 300, { leading: true, trailing: false })
238
+ );
239
+
234
240
  // Attachment action handler
235
241
  const handleAttachmentActionInternal = useCallback(
236
242
  async (action: any) => {
237
243
  if (action.id === 'gallery') {
238
- await handleImageAttach();
244
+ debouncedImagePickerRef.current();
239
245
  } else {
240
246
  onAttachmentAction?.(action);
241
247
  }
242
248
  },
243
- [onAttachmentAction, handleImageAttach]
249
+ [onAttachmentAction]
244
250
  );
245
251
 
246
252
  // Input row padding
@@ -73,25 +73,31 @@ export const useAttachmentSendHandler = ({
73
73
  );
74
74
 
75
75
  // Auto-generate thumbnails for gallery videos
76
+ // Use unique ID per video to avoid race conditions and path mismatches
76
77
  const galleryVideoThumbnails: Record<
77
78
  string,
78
79
  { url: string; size: number }
79
80
  > = {};
81
+ const failedVideos: string[] = []; // Track failed videos
80
82
 
81
83
  if (uploadedVideosFromGallery.length > 0) {
82
84
  console.log(
83
85
  '[ChatComposer] Auto-generating thumbnails for gallery videos...'
84
86
  );
85
87
 
86
- for (const vidItem of uploadedVideosFromGallery) {
88
+ for (let idx = 0; idx < uploadedVideosFromGallery.length; idx++) {
89
+ const vidItem = uploadedVideosFromGallery[idx]!;
90
+ const videoId = vidItem.uploadedUrl || `video-${idx}`; // Use unique ID instead of path
87
91
  let cachedVideoPath: string | null = null;
92
+
88
93
  try {
89
94
  const originalVideoPath = vidItem.image.path;
90
- const videoDuration = (vidItem.image as any).duration || 0;
95
+ let videoDuration = (vidItem.image as any).duration || 0;
91
96
  const videoMime = (vidItem.image as any).mime;
92
97
  const filename = vidItem.image.filename || 'video.mp4';
93
98
 
94
- console.log('[ChatComposer] Generating thumbnail for:', {
99
+ console.log('[ChatComposer] Generating thumbnail for video:', {
100
+ index: idx,
95
101
  filename,
96
102
  originalVideoPath,
97
103
  videoDuration,
@@ -99,9 +105,20 @@ export const useAttachmentSendHandler = ({
99
105
  uploadedUrl: vidItem.uploadedUrl,
100
106
  });
101
107
 
108
+ // ✅ FIX: Validate duration is reasonable
109
+ if (videoDuration <= 0 || videoDuration > 3600000) {
110
+ // Duration should be between 0 and 1 hour (3600000ms)
111
+ console.warn(
112
+ '[ChatComposer] Invalid duration, using default:',
113
+ videoDuration
114
+ );
115
+ videoDuration = 5000; // Default 5 seconds
116
+ }
117
+
102
118
  // Copy video to persistent location
103
119
  const ext = filename.split('.').pop() || 'mp4';
104
- const cachedFileName = `thumb-cache-${Date.now()}-${Math.random()
120
+ // FIX: Use unique ID to avoid race condition
121
+ const cachedFileName = `thumb-cache-${idx}-${Date.now()}-${Math.random()
105
122
  .toString(36)
106
123
  .slice(2)}.${ext}`;
107
124
  cachedVideoPath = `${RNFS.DocumentDirectoryPath}/${cachedFileName}`;
@@ -114,10 +131,16 @@ export const useAttachmentSendHandler = ({
114
131
  await RNFS.copyFile(originalVideoPath, cachedVideoPath);
115
132
  console.log('[ChatComposer] Video cached successfully');
116
133
 
117
- // Use smaller time offset if video is very short
118
- const timeMs = Math.min(1000, Math.floor(videoDuration / 2));
134
+ // FIX: Choose frame at 30-40% for better representation
135
+ // (not exactly middle, to avoid black frames/transitions)
136
+ const timeMs = Math.min(
137
+ Math.max(1000, Math.floor(videoDuration * 0.35)),
138
+ videoDuration - 1000
139
+ );
119
140
 
120
- console.log('[ChatComposer] Thumbnail settings:', {
141
+ console.log('[ChatComposer] Thumbnail settings for video:', {
142
+ index: idx,
143
+ videoDuration,
121
144
  timeMs,
122
145
  quality: 80,
123
146
  videoPath: cachedVideoPath,
@@ -126,21 +149,27 @@ export const useAttachmentSendHandler = ({
126
149
  // Generate thumbnail from cached copy
127
150
  const thumbnail = await VideoThumbnailUtil.generateThumbnail(
128
151
  cachedVideoPath,
129
- { time: timeMs, quality: 80 }
152
+ {
153
+ time: timeMs,
154
+ quality: 80,
155
+ cacheId: `thumb-${idx}-${Date.now()}`,
156
+ }
130
157
  );
131
158
 
132
159
  if (!thumbnail) {
133
160
  throw new Error('Failed to generate thumbnail');
134
161
  }
135
162
 
136
- console.log('[ChatComposer] Thumbnail generated:', {
163
+ console.log('[ChatComposer] Thumbnail generated for video:', {
164
+ index: idx,
137
165
  path: thumbnail.path,
138
166
  width: thumbnail.width,
139
167
  height: thumbnail.height,
140
168
  });
141
169
 
142
170
  // Upload thumbnail
143
- const baseName = vidItem.image.filename?.split('.')[0] || 'video';
171
+ const baseName =
172
+ vidItem.image.filename?.split('.')[0] || `video-${idx}`;
144
173
  const uploadedThumbnail = await ImageUploadAPI.uploadImageFile(
145
174
  {
146
175
  path: thumbnail.path,
@@ -152,6 +181,7 @@ export const useAttachmentSendHandler = ({
152
181
  },
153
182
  (progress) => {
154
183
  console.log('[ChatComposer] Thumbnail upload progress:', {
184
+ videoIndex: idx,
155
185
  percentage: progress,
156
186
  });
157
187
  }
@@ -161,33 +191,41 @@ export const useAttachmentSendHandler = ({
161
191
  throw new Error('No thumbnail URL returned');
162
192
  }
163
193
 
164
- // Store thumbnail data
165
- galleryVideoThumbnails[vidItem.image.path] = {
194
+ // ✅ FIX: Store with unique ID instead of path
195
+ galleryVideoThumbnails[videoId] = {
166
196
  url: uploadedThumbnail.url,
167
197
  size: uploadedThumbnail.size || 0,
168
198
  };
169
199
 
170
- console.log('[ChatComposer] Thumbnail ready:', {
200
+ console.log('[ChatComposer] Thumbnail ready for video:', {
201
+ videoIndex: idx,
202
+ videoId,
171
203
  url: uploadedThumbnail.url,
172
204
  size: uploadedThumbnail.size,
173
205
  });
174
206
  console.log(
175
- `✅ [ChatComposer] THUMBNAIL URL: ${uploadedThumbnail.url}`
207
+ `✅ [ChatComposer] Video #${idx} THUMBNAIL URL: ${uploadedThumbnail.url}`
176
208
  );
177
209
  } catch (error) {
178
- console.error('[ChatComposer] Thumbnail generation error:', error);
179
- UIUtils.toast.open({
180
- title: 'Thumbnail Error',
181
- message: `Failed to generate thumbnail for ${vidItem.image.filename}`,
182
- theme: 'danger',
183
- });
184
- return;
210
+ // FIX: Skip failed video instead of aborting all
211
+ const errorMsg =
212
+ error instanceof Error ? error.message : String(error);
213
+ console.error(
214
+ '[ChatComposer] Thumbnail generation error for video:',
215
+ idx,
216
+ errorMsg
217
+ );
218
+ failedVideos.push(vidItem.image.filename);
219
+
220
+ // Mark this video as failed (no thumbnail)
221
+ galleryVideoThumbnails[videoId] = { url: '', size: 0 };
185
222
  } finally {
186
223
  // Cleanup cached video file
187
224
  if (cachedVideoPath) {
188
225
  try {
189
226
  await RNFS.unlink(cachedVideoPath);
190
227
  console.log('[ChatComposer] Cleaned up cached video:', {
228
+ videoIndex: idx,
191
229
  path: cachedVideoPath,
192
230
  });
193
231
  } catch (err) {
@@ -196,6 +234,19 @@ export const useAttachmentSendHandler = ({
196
234
  }
197
235
  }
198
236
  }
237
+
238
+ // Show error about failed videos if any
239
+ if (failedVideos.length > 0) {
240
+ UIUtils.toast.open({
241
+ title: 'Thumbnail Generation Failed',
242
+ message: `Failed for: ${failedVideos.join(', ')}. Videos will be sent without thumbnails.`,
243
+ theme: 'warning',
244
+ });
245
+ console.warn(
246
+ '[ChatComposer] Failed to generate thumbnails for:',
247
+ failedVideos
248
+ );
249
+ }
199
250
  }
200
251
 
201
252
  const allUploadedVideos = [...uploadedVideosFromGallery];
@@ -210,10 +261,13 @@ export const useAttachmentSendHandler = ({
210
261
 
211
262
  // Log thumbnail URLs
212
263
  if (uploadedVideosFromGallery.length > 0) {
213
- uploadedVideosFromGallery.forEach((vid: any) => {
214
- const thumbnail = galleryVideoThumbnails[vid.image.path];
264
+ uploadedVideosFromGallery.forEach((vid: any, vidIdx: number) => {
265
+ // Use same key as storage: uploadedUrl or video-${idx}
266
+ const videoId = vid.uploadedUrl || `video-${vidIdx}`;
267
+ const thumbnail = galleryVideoThumbnails[videoId];
215
268
  console.log(`✅ [ChatComposer] Gallery Video Thumbnail:`, {
216
269
  filename: vid.image.filename,
270
+ videoId,
217
271
  thumbnailUrl: thumbnail?.url || 'NOT FOUND',
218
272
  videoUrl: vid.uploadedUrl,
219
273
  });
@@ -228,8 +282,10 @@ export const useAttachmentSendHandler = ({
228
282
  type: 'image' as const,
229
283
  })),
230
284
  // Videos from gallery picker with auto-generated thumbnails
231
- ...uploadedVideosFromGallery.map((vid: any) => {
232
- const thumbnail = galleryVideoThumbnails[vid.image.path];
285
+ ...uploadedVideosFromGallery.map((vid: any, vidIdx: number) => {
286
+ // Use same key as storage: uploadedUrl or video-${idx}
287
+ const videoId = vid.uploadedUrl || `video-${vidIdx}`;
288
+ const thumbnail = galleryVideoThumbnails[videoId];
233
289
  return {
234
290
  type: 'video' as const,
235
291
  video: vid.image,
@@ -9,7 +9,14 @@ export const ImageAttachmentHandler: IAttachmentHandler = {
9
9
  return { valid: false, reason: 'Image exceeds 5MB limit' };
10
10
  }
11
11
 
12
- const validMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];
12
+ const validMimes = [
13
+ 'image/jpeg',
14
+ 'image/png',
15
+ 'image/webp',
16
+ 'image/jpg',
17
+ 'image/heic',
18
+ 'image/heif',
19
+ ];
13
20
  if (!validMimes.includes(attachment.sourceFile.mimeType)) {
14
21
  return {
15
22
  valid: false,
@@ -28,6 +35,7 @@ export const ImageAttachmentHandler: IAttachmentHandler = {
28
35
  console.log('[ImageAttachmentHandler] Building payload for', {
29
36
  filename: attachment.sourceFile.filename,
30
37
  url: uploadedUrl,
38
+ // path: attachment.sourceFile.path,
31
39
  });
32
40
 
33
41
  const picInfo = {
@@ -43,10 +51,12 @@ export const ImageAttachmentHandler: IAttachmentHandler = {
43
51
  sourcePicture: picInfo,
44
52
  bigPicture: picInfo,
45
53
  snapshotPicture: picInfo,
54
+ sourcePath: attachment.sourceFile.path, // Required by OpenIM SDK to attach image to message
46
55
  };
47
56
 
48
57
  console.log('[ImageAttachmentHandler] Payload created for', {
49
58
  fileName: attachment.sourceFile.filename,
59
+ sourcePath: attachment.sourceFile.path,
50
60
  });
51
61
 
52
62
  return payload;
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  let authToken: string | null = null;
8
+ let tokenRefreshCallback: (() => Promise<string>) | null = null;
9
+ let tokenRefreshFailedCallback: (() => void) | null = null;
8
10
 
9
11
  /**
10
12
  * Set auth token (call once at app startup)
@@ -26,9 +28,84 @@ export const getAuthToken = (): string => {
26
28
  return authToken;
27
29
  };
28
30
 
31
+ /**
32
+ * Set callback for token refresh when 401 Unauthorized occurs
33
+ * Callback should refresh token from server and return the new token
34
+ * Example:
35
+ * setTokenRefreshCallback(async () => {
36
+ * const newToken = await api.post('/auth/refresh');
37
+ * return newToken;
38
+ * });
39
+ */
40
+ export const setTokenRefreshCallback = (
41
+ callback: () => Promise<string>
42
+ ): void => {
43
+ tokenRefreshCallback = callback;
44
+ };
45
+
46
+ /**
47
+ * Set callback when token refresh fails
48
+ * Called when token refresh throws error or returns invalid token
49
+ * Use this to redirect to login, show error message, etc.
50
+ * Example:
51
+ * setTokenRefreshFailedCallback(() => {
52
+ * navigation.navigate('Login');
53
+ * showToast('Your session expired. Please login again.');
54
+ * });
55
+ */
56
+ export const setTokenRefreshFailedCallback = (callback: () => void): void => {
57
+ tokenRefreshFailedCallback = callback;
58
+ };
59
+
60
+ /**
61
+ * Refresh auth token when 401 Unauthorized occurs
62
+ * Calls the refresh callback and updates the stored token
63
+ * If refresh fails:
64
+ * 1. Clears the invalid token
65
+ * 2. Calls tokenRefreshFailedCallback (if set) to notify app
66
+ * 3. Throws error
67
+ * @returns new auth token
68
+ * @throws Error if callback not set or refresh fails
69
+ */
70
+ export const refreshAuthToken = async (): Promise<string> => {
71
+ if (!tokenRefreshCallback) {
72
+ throw new Error(
73
+ 'Token refresh callback not set. Call setTokenRefreshCallback() during app initialization.'
74
+ );
75
+ }
76
+
77
+ try {
78
+ const newToken = await tokenRefreshCallback();
79
+ if (!newToken) {
80
+ throw new Error('Refresh callback returned empty token');
81
+ }
82
+ setAuthToken(newToken);
83
+ return newToken;
84
+ } catch (error) {
85
+ // Clear invalid token
86
+ clearAuthToken();
87
+
88
+ // Notify app that refresh failed
89
+ if (tokenRefreshFailedCallback) {
90
+ try {
91
+ tokenRefreshFailedCallback();
92
+ } catch (callbackError) {
93
+ console.error(
94
+ '[Auth] Token refresh failed callback error:',
95
+ callbackError
96
+ );
97
+ }
98
+ }
99
+
100
+ // Throw original error
101
+ throw error instanceof Error ? error : new Error('Token refresh failed');
102
+ }
103
+ };
104
+
29
105
  /**
30
106
  * Clear token (logout)
31
107
  */
32
108
  export const clearAuthToken = () => {
33
109
  authToken = null;
110
+ tokenRefreshCallback = null;
34
111
  };
@@ -1,9 +1,9 @@
1
1
  import ReactNativeBlobUtil from 'react-native-blob-util';
2
2
  import RNFS from 'react-native-fs';
3
3
  import { getImageUploadEndpoint, getMimeType } from '../utils/imageUtils';
4
- import { getAuthToken } from './auth';
4
+ import { getAuthToken, refreshAuthToken } from './auth';
5
5
  import { getApiEndpoint } from '../config/api-endpoints';
6
- import { getBaseUrl } from '../config/configuration';
6
+ import { apiInstance } from './apis';
7
7
  import type {
8
8
  ImageFile,
9
9
  UploadedImage,
@@ -11,31 +11,12 @@ import type {
11
11
  } from '../types/imageUpload';
12
12
 
13
13
  export namespace ImageUploadAPI {
14
- export const uploadImageFile = async (
14
+ const performUpload = async (
15
+ uploadUrl: string,
15
16
  file: ImageFile,
17
+ token: string,
16
18
  onProgress?: (progress: number) => void
17
- ): Promise<UploadedImage> => {
18
- let token: string;
19
- let uploadUrl: string;
20
-
21
- try {
22
- token = getAuthToken();
23
- const relativePath = getApiEndpoint('imageUpload');
24
- const baseUrl = getBaseUrl();
25
-
26
- if (!baseUrl) {
27
- throw new Error(
28
- 'Image upload base URL not configured. Call initChatSdk() with apiBaseUrl during app initialization.'
29
- );
30
- }
31
-
32
- // Construct absolute URL for ReactNativeBlobUtil.fetch()
33
- uploadUrl = getImageUploadEndpoint(`${baseUrl}${relativePath}`);
34
- } catch (e) {
35
- if (__DEV__) console.error('[ImageUploadAPI] Auth error:', e);
36
- throw e;
37
- }
38
-
19
+ ): Promise<any> => {
39
20
  return new Promise((resolve, reject) => {
40
21
  ReactNativeBlobUtil.fetch(
41
22
  'POST',
@@ -56,57 +37,119 @@ export namespace ImageUploadAPI {
56
37
  const progress = Math.round((written / total) * 100);
57
38
  onProgress?.(progress);
58
39
  })
59
- .then((response) => {
60
- // Check for HTTP error status
61
- const status = response.respInfo?.status;
62
- if (status && status >= 400) {
63
- if (__DEV__)
64
- console.error('[ImageUploadAPI] Server error response', {
65
- status,
66
- data: response.data,
67
- });
68
- reject(new Error(`Upload failed with status ${status}`));
69
- return;
70
- }
40
+ .then((response) => resolve(response))
41
+ .catch((error) => reject(error));
42
+ });
43
+ };
71
44
 
72
- if (!response.data || response.data.length === 0) {
73
- if (__DEV__)
74
- console.error('[ImageUploadAPI] Empty response', {
75
- dataType: typeof response.data,
76
- data: response.data,
77
- });
78
- reject(new Error('Empty response from server'));
79
- return;
80
- }
45
+ const parseUploadResponse = (responseData: string): UploadedImage | null => {
46
+ try {
47
+ const json = JSON.parse(responseData);
48
+ const uploaded = json.data?.[0] || json.files?.[0];
49
+ const imageUrl = uploaded?.publicUrl || uploaded?.url || uploaded?.data;
50
+ if (imageUrl) {
51
+ return {
52
+ url: imageUrl,
53
+ name: uploaded?.fileName,
54
+ width: uploaded?.width,
55
+ height: uploaded?.height,
56
+ size: uploaded?.size,
57
+ };
58
+ }
59
+ return null;
60
+ } catch (err) {
61
+ if (__DEV__) console.error('[ImageUploadAPI] Parse error:', err);
62
+ return null;
63
+ }
64
+ };
81
65
 
82
- try {
83
- const json = JSON.parse(response.data);
84
- const uploaded = json.data?.[0] || json.files?.[0];
85
- const imageUrl =
86
- uploaded?.publicUrl || uploaded?.url || uploaded?.data;
87
- if (imageUrl) {
88
- resolve({
89
- url: imageUrl,
90
- name: uploaded?.fileName,
91
- width: file.width,
92
- height: file.height,
93
- size: file.size,
94
- });
95
- } else {
96
- if (__DEV__)
97
- console.error('[ImageUploadAPI] No URL in response:', json);
98
- reject(new Error('No URL in response'));
99
- }
100
- } catch (err) {
101
- if (__DEV__) console.error('[ImageUploadAPI] Parse error:', err);
102
- reject(new Error('Invalid response format'));
103
- }
104
- })
105
- .catch((error) => {
106
- if (__DEV__) console.error('[ImageUploadAPI] Fetch error:', error);
107
- reject(error);
66
+ export const uploadImageFile = async (
67
+ file: ImageFile,
68
+ onProgress?: (progress: number) => void
69
+ ): Promise<UploadedImage> => {
70
+ let token: string;
71
+ let uploadUrl: string;
72
+
73
+ try {
74
+ token = getAuthToken();
75
+ const relativePath = getApiEndpoint('imageUpload');
76
+ const baseUrl = apiInstance?.defaults?.baseURL;
77
+
78
+ if (!baseUrl) {
79
+ throw new Error(
80
+ 'Image upload base URL not configured. Call initApiInstance() during app initialization.'
81
+ );
82
+ }
83
+
84
+ uploadUrl = getImageUploadEndpoint(`${baseUrl}${relativePath}`);
85
+ } catch (e) {
86
+ if (__DEV__) console.error('[ImageUploadAPI] Auth error:', e);
87
+ throw e;
88
+ }
89
+
90
+ let response = await performUpload(uploadUrl, file, token, onProgress);
91
+ const status = response.respInfo?.status;
92
+
93
+ // Handle 401 Unauthorized - refresh token and retry once
94
+ if (status === 401) {
95
+ if (__DEV__)
96
+ console.warn('[ImageUploadAPI] Got 401, attempting token refresh...');
97
+
98
+ try {
99
+ const newToken = await refreshAuthToken();
100
+ if (__DEV__)
101
+ console.log('[ImageUploadAPI] Token refreshed, retrying upload...');
102
+
103
+ // Retry upload with new token
104
+ response = await performUpload(uploadUrl, file, newToken, onProgress);
105
+ const retryStatus = response.respInfo?.status;
106
+
107
+ if (retryStatus === 401) {
108
+ throw new Error(
109
+ 'Auth token still invalid after refresh. (401 Unauthorized)'
110
+ );
111
+ }
112
+
113
+ if (retryStatus && retryStatus >= 400) {
114
+ throw new Error(`Upload failed with status ${retryStatus}`);
115
+ }
116
+ } catch (refreshError) {
117
+ if (__DEV__)
118
+ console.error('[ImageUploadAPI] Token refresh failed:', refreshError);
119
+ throw refreshError instanceof Error
120
+ ? refreshError
121
+ : new Error('Token refresh failed');
122
+ }
123
+ } else if (status && status >= 400) {
124
+ if (__DEV__)
125
+ console.error('[ImageUploadAPI] Server error response', {
126
+ status,
127
+ data: response.data,
108
128
  });
109
- });
129
+ throw new Error(`Upload failed with status ${status}`);
130
+ }
131
+
132
+ if (!response.data || response.data.length === 0) {
133
+ if (__DEV__)
134
+ console.error('[ImageUploadAPI] Empty response', {
135
+ dataType: typeof response.data,
136
+ data: response.data,
137
+ });
138
+ throw new Error('Empty response from server');
139
+ }
140
+
141
+ const uploadedImage = parseUploadResponse(response.data);
142
+ if (!uploadedImage) {
143
+ throw new Error('No URL in response');
144
+ }
145
+
146
+ return {
147
+ url: uploadedImage.url,
148
+ name: uploadedImage.name,
149
+ width: file.width,
150
+ height: file.height,
151
+ size: file.size,
152
+ };
110
153
  };
111
154
 
112
155
  export const uploadImageQueue = async (
@@ -139,6 +139,7 @@ class ChatImageDimens {
139
139
  /**
140
140
  * Get optimized image URL with custom dimensions (generic approach)
141
141
  * Useful for dynamic sizing based on actual content
142
+ * Automatically applies pixel ratio for high-DPI displays
142
143
  */
143
144
  public static getOptimizedImageUrlWithDimensions(
144
145
  url: string | undefined,
@@ -154,6 +155,20 @@ class ChatImageDimens {
154
155
  return url;
155
156
  }
156
157
 
158
+ // Check if image format is supported for WebP conversion
159
+ // Only convert standard formats (jpg, jpeg, png) to WebP
160
+ // For unsupported formats (heic, heif, etc.), return original URL
161
+ const unsupportedFormats = ['.heic', '.heif'];
162
+ const urlLower = url.toLowerCase();
163
+ const isUnsupportedFormat = unsupportedFormats.some((format) =>
164
+ urlLower.includes(format)
165
+ );
166
+
167
+ if (isUnsupportedFormat) {
168
+ // Return original URL without optimization for unsupported formats
169
+ return url;
170
+ }
171
+
157
172
  const domainRegex = /^(https?:\/\/[^/]+)/;
158
173
  const domainMatches = url.match(domainRegex);
159
174
 
@@ -164,8 +179,13 @@ class ChatImageDimens {
164
179
  const domain = domainMatches[1];
165
180
  const restOfUrl = url.replace(domain as string, '');
166
181
 
182
+ // Account for device pixel ratio: request higher resolution for high-DPI displays
183
+ const pixelRatio = PixelRatio.get();
184
+ const optimizedWidth = Math.round(width * pixelRatio);
185
+ const optimizedHeight = Math.round(height * pixelRatio);
186
+
167
187
  // Build optimized URL: domain/cache/WIDTHxHEIGHT/path.webp
168
- let optimizedUrl = `${domain}/cache/${width}x${height}${restOfUrl}`;
188
+ let optimizedUrl = `${domain}/cache/${optimizedWidth}x${optimizedHeight}${restOfUrl}`;
169
189
 
170
190
  if (!restOfUrl.endsWith('.webp')) {
171
191
  optimizedUrl += '.webp';