@ethora/chat-component-rn 26.10.9 → 26.10.10

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 (154) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +1 -1
  3. package/lib/commonjs/components/MainComponents/AlbumGrid.js +6 -2
  4. package/lib/commonjs/components/MainComponents/AlbumGrid.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatHeader.js +16 -11
  6. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/MediaMessage.js +13 -0
  8. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/RoomList.js +7 -16
  10. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  11. package/lib/commonjs/components/MessageBubble/Message.js +27 -1
  12. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  13. package/lib/commonjs/components/Modals/ChatProfileModal/ChatMediaGrid.js +159 -0
  14. package/lib/commonjs/components/Modals/ChatProfileModal/ChatMediaGrid.js.map +1 -0
  15. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +203 -134
  16. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  17. package/lib/commonjs/components/Modals/ChatProfileModal/LeaveChatModal.js +180 -0
  18. package/lib/commonjs/components/Modals/ChatProfileModal/LeaveChatModal.js.map +1 -0
  19. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +100 -162
  20. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  21. package/lib/commonjs/components/Modals/FilePreviewModal/MediaViewer.js +824 -0
  22. package/lib/commonjs/components/Modals/FilePreviewModal/MediaViewer.js.map +1 -0
  23. package/lib/commonjs/components/Modals/FilePreviewModal/useSaveFile.js +179 -0
  24. package/lib/commonjs/components/Modals/FilePreviewModal/useSaveFile.js.map +1 -0
  25. package/lib/commonjs/components/Modals/MessageSearchModal/MessageSearchModal.js +22 -5
  26. package/lib/commonjs/components/Modals/MessageSearchModal/MessageSearchModal.js.map +1 -1
  27. package/lib/commonjs/components/styled/MessageImage.js +4 -1
  28. package/lib/commonjs/components/styled/MessageImage.js.map +1 -1
  29. package/lib/commonjs/components/styled/VideoMessage.js +5 -1
  30. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  31. package/lib/commonjs/context/MediaSourceContext.js +18 -0
  32. package/lib/commonjs/context/MediaSourceContext.js.map +1 -0
  33. package/lib/commonjs/helpers/constants/MODAL_COMPONENTS.js +3 -1
  34. package/lib/commonjs/helpers/constants/MODAL_COMPONENTS.js.map +1 -1
  35. package/lib/commonjs/helpers/constants/MODAL_TYPES.js +2 -0
  36. package/lib/commonjs/helpers/constants/MODAL_TYPES.js.map +1 -1
  37. package/lib/commonjs/helpers/publicChatsEntry.js +8 -6
  38. package/lib/commonjs/helpers/publicChatsEntry.js.map +1 -1
  39. package/lib/commonjs/helpers/roomMedia.js +118 -0
  40. package/lib/commonjs/helpers/roomMedia.js.map +1 -0
  41. package/lib/commonjs/i18n/strings.js +36 -0
  42. package/lib/commonjs/i18n/strings.js.map +1 -1
  43. package/lib/commonjs/networking/api-requests/rooms.api.js +9 -13
  44. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  45. package/lib/commonjs/version.js +1 -1
  46. package/lib/commonjs/version.js.map +1 -1
  47. package/lib/module/components/MainComponents/AlbumGrid.js +6 -2
  48. package/lib/module/components/MainComponents/AlbumGrid.js.map +1 -1
  49. package/lib/module/components/MainComponents/ChatHeader.js +18 -13
  50. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  51. package/lib/module/components/MainComponents/MediaMessage.js +13 -0
  52. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  53. package/lib/module/components/MainComponents/RoomList.js +7 -16
  54. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  55. package/lib/module/components/MessageBubble/Message.js +27 -1
  56. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  57. package/lib/module/components/Modals/ChatProfileModal/ChatMediaGrid.js +154 -0
  58. package/lib/module/components/Modals/ChatProfileModal/ChatMediaGrid.js.map +1 -0
  59. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +204 -136
  60. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  61. package/lib/module/components/Modals/ChatProfileModal/LeaveChatModal.js +175 -0
  62. package/lib/module/components/Modals/ChatProfileModal/LeaveChatModal.js.map +1 -0
  63. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +100 -163
  64. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  65. package/lib/module/components/Modals/FilePreviewModal/MediaViewer.js +819 -0
  66. package/lib/module/components/Modals/FilePreviewModal/MediaViewer.js.map +1 -0
  67. package/lib/module/components/Modals/FilePreviewModal/useSaveFile.js +172 -0
  68. package/lib/module/components/Modals/FilePreviewModal/useSaveFile.js.map +1 -0
  69. package/lib/module/components/Modals/MessageSearchModal/MessageSearchModal.js +20 -4
  70. package/lib/module/components/Modals/MessageSearchModal/MessageSearchModal.js.map +1 -1
  71. package/lib/module/components/styled/MessageImage.js +4 -1
  72. package/lib/module/components/styled/MessageImage.js.map +1 -1
  73. package/lib/module/components/styled/VideoMessage.js +5 -1
  74. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  75. package/lib/module/context/MediaSourceContext.js +13 -0
  76. package/lib/module/context/MediaSourceContext.js.map +1 -0
  77. package/lib/module/helpers/constants/MODAL_COMPONENTS.js +2 -1
  78. package/lib/module/helpers/constants/MODAL_COMPONENTS.js.map +1 -1
  79. package/lib/module/helpers/constants/MODAL_TYPES.js +2 -0
  80. package/lib/module/helpers/constants/MODAL_TYPES.js.map +1 -1
  81. package/lib/module/helpers/publicChatsEntry.js +8 -6
  82. package/lib/module/helpers/publicChatsEntry.js.map +1 -1
  83. package/lib/module/helpers/roomMedia.js +112 -0
  84. package/lib/module/helpers/roomMedia.js.map +1 -0
  85. package/lib/module/i18n/strings.js +36 -0
  86. package/lib/module/i18n/strings.js.map +1 -1
  87. package/lib/module/networking/api-requests/rooms.api.js +9 -13
  88. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  89. package/lib/module/version.js +1 -1
  90. package/lib/module/version.js.map +1 -1
  91. package/lib/typescript/components/MainComponents/AlbumGrid.d.ts.map +1 -1
  92. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  93. package/lib/typescript/components/MainComponents/MediaMessage.d.ts +3 -1
  94. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  95. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  96. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  97. package/lib/typescript/components/Modals/ChatProfileModal/ChatMediaGrid.d.ts +16 -0
  98. package/lib/typescript/components/Modals/ChatProfileModal/ChatMediaGrid.d.ts.map +1 -0
  99. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  100. package/lib/typescript/components/Modals/ChatProfileModal/LeaveChatModal.d.ts +18 -0
  101. package/lib/typescript/components/Modals/ChatProfileModal/LeaveChatModal.d.ts.map +1 -0
  102. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts +16 -0
  103. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  104. package/lib/typescript/components/Modals/FilePreviewModal/MediaViewer.d.ts +27 -0
  105. package/lib/typescript/components/Modals/FilePreviewModal/MediaViewer.d.ts.map +1 -0
  106. package/lib/typescript/components/Modals/FilePreviewModal/useSaveFile.d.ts +9 -0
  107. package/lib/typescript/components/Modals/FilePreviewModal/useSaveFile.d.ts.map +1 -0
  108. package/lib/typescript/components/Modals/MessageSearchModal/MessageSearchModal.d.ts +8 -0
  109. package/lib/typescript/components/Modals/MessageSearchModal/MessageSearchModal.d.ts.map +1 -1
  110. package/lib/typescript/components/styled/MessageImage.d.ts.map +1 -1
  111. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  112. package/lib/typescript/context/MediaSourceContext.d.ts +4 -0
  113. package/lib/typescript/context/MediaSourceContext.d.ts.map +1 -0
  114. package/lib/typescript/helpers/constants/MODAL_COMPONENTS.d.ts.map +1 -1
  115. package/lib/typescript/helpers/constants/MODAL_TYPES.d.ts +2 -0
  116. package/lib/typescript/helpers/constants/MODAL_TYPES.d.ts.map +1 -1
  117. package/lib/typescript/helpers/publicChatsEntry.d.ts +7 -5
  118. package/lib/typescript/helpers/publicChatsEntry.d.ts.map +1 -1
  119. package/lib/typescript/helpers/roomMedia.d.ts +26 -0
  120. package/lib/typescript/helpers/roomMedia.d.ts.map +1 -0
  121. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  122. package/lib/typescript/networking/api-requests/rooms.api.d.ts +6 -5
  123. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  124. package/lib/typescript/types/models/modal.model.d.ts +18 -0
  125. package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
  126. package/lib/typescript/types/types.d.ts +18 -0
  127. package/lib/typescript/types/types.d.ts.map +1 -1
  128. package/lib/typescript/version.d.ts +1 -1
  129. package/lib/typescript/version.d.ts.map +1 -1
  130. package/package.json +9 -2
  131. package/src/components/MainComponents/AlbumGrid.tsx +5 -1
  132. package/src/components/MainComponents/ChatHeader.tsx +16 -11
  133. package/src/components/MainComponents/MediaMessage.tsx +13 -2
  134. package/src/components/MainComponents/RoomList.tsx +8 -19
  135. package/src/components/MessageBubble/Message.tsx +24 -1
  136. package/src/components/Modals/ChatProfileModal/ChatMediaGrid.tsx +174 -0
  137. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +119 -48
  138. package/src/components/Modals/ChatProfileModal/LeaveChatModal.tsx +191 -0
  139. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +115 -185
  140. package/src/components/Modals/FilePreviewModal/MediaViewer.tsx +880 -0
  141. package/src/components/Modals/FilePreviewModal/useSaveFile.ts +207 -0
  142. package/src/components/Modals/MessageSearchModal/MessageSearchModal.tsx +56 -30
  143. package/src/components/styled/MessageImage.tsx +3 -1
  144. package/src/components/styled/VideoMessage.tsx +5 -1
  145. package/src/context/MediaSourceContext.tsx +14 -0
  146. package/src/helpers/constants/MODAL_COMPONENTS.tsx +4 -1
  147. package/src/helpers/constants/MODAL_TYPES.ts +2 -0
  148. package/src/helpers/publicChatsEntry.ts +8 -7
  149. package/src/helpers/roomMedia.ts +126 -0
  150. package/src/i18n/strings.ts +36 -0
  151. package/src/networking/api-requests/rooms.api.ts +13 -12
  152. package/src/types/models/modal.model.ts +16 -0
  153. package/src/types/types.ts +16 -0
  154. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { withFileToken } from '../../../helpers/secureFileUrl';
3
3
  import styled from 'styled-components/native';
4
4
  import {
@@ -10,26 +10,28 @@ import ModalHeaderComponent from '../ModalHeaderComponent';
10
10
  import { useChatSettingState } from '../../../hooks/useChatSettingState';
11
11
  import { useTheme } from '../../../hooks/useTheme';
12
12
  import { chatTextStyle } from '../../../helpers/typography';
13
- import { useDispatch, useSelector } from 'react-redux';
13
+ import { useDispatch, useSelector, useStore } from 'react-redux';
14
14
  import Button from '../../styled/Button';
15
15
  import { RootState } from '../../../roomStore';
16
16
  import { FullScreenImage } from '../../styled/StyledInputComponents/MediaComponents';
17
17
  import { setActiveFile } from '../../../roomStore/chatSettingsSlice';
18
+ import type { ModalFile } from '../../../types/types';
19
+ import MediaViewer from './MediaViewer';
20
+ import { isLocalFile, useSaveFile } from './useSaveFile';
21
+ import { withChatSiblings } from '../../../helpers/roomMedia';
22
+ import { loadRoomHistory } from '../../../helpers/loadRoomHistory';
23
+ import { getGlobalXmppClient } from '../../../utils/clientRegistry';
24
+ import { useFileToken } from '../../../hooks/useFileToken';
18
25
  import {
19
26
  ActivityIndicator,
20
- Alert,
21
27
  Text,
22
28
  View,
23
29
  Platform,
24
30
  TouchableOpacity,
25
31
  StyleSheet,
26
- Share,
27
32
  } from 'react-native';
28
33
  import { useVideoPlayer, VideoView } from 'expo-video';
29
34
  import { PlayIcon } from '../../../assets/icons';
30
- import * as FileSystem from 'expo-file-system/legacy';
31
- import { getMediaLibrary } from '../../../helpers/mediaLibraryRuntime';
32
- import { useToast } from '../../../context/ToastContext';
33
35
  import { useT } from '../../../i18n/useT';
34
36
  import PdfViewer from './PdfView';
35
37
  import DocumentViewer from './DocumentViewer';
@@ -44,7 +46,6 @@ import {
44
46
  } from '../../../helpers/videoCache';
45
47
  import {
46
48
  getDisplayFileName,
47
- getUniqueFileName,
48
49
  sanitizeFileNameForPath,
49
50
  } from '../../../helpers/getDisplayFileName';
50
51
 
@@ -62,13 +63,12 @@ const GVIEW_PREVIEWABLE = new Set<string>([
62
63
  'application/rtf',
63
64
  ]);
64
65
 
65
- const isLocalFile = (url: string | undefined | null) =>
66
- /^file:\/\//i.test(url || '');
66
+ const isMedia = (mime: string | undefined | null) =>
67
+ !!mime && (mime.startsWith('image/') || mime.startsWith('video/'));
67
68
 
68
- const fetchToCache = async (url: string, cachePath: string) =>
69
- isLocalFile(url)
70
- ? { status: 200, uri: url }
71
- : FileSystem.downloadAsync(withFileToken(url), cachePath);
69
+ /** Older history pages the viewer asked for; one at a time per room. */
70
+ const historyInFlight = new Set<string>();
71
+ const HISTORY_PAGE_SIZE = 30;
72
72
 
73
73
  const isGviewPreviewable = (mime: string | undefined | null) => {
74
74
  if (!mime) {return false;}
@@ -246,11 +246,94 @@ interface FilePreviewModalProps {
246
246
  handleCloseModal: any;
247
247
  }
248
248
 
249
+ /** The viewer on the store's `activeFile`, as opened from a message bubble. */
249
250
  const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
250
251
  handleCloseModal,
251
252
  }) => {
252
253
  const dispatch = useDispatch();
253
- const { showToast } = useToast();
254
+ const store = useStore<RootState>();
255
+ const { activeFile } = useSelector(
256
+ (state: RootState) => state.chatSettingStore
257
+ );
258
+ const { user, config } = useChatSettingState();
259
+ const fileToken = useFileToken();
260
+ const roomJid = activeFile?.source?.roomJid;
261
+ const room = useSelector((state: RootState) =>
262
+ roomJid ? state.rooms.rooms?.[roomJid] : undefined
263
+ );
264
+ const messages = room?.messages;
265
+
266
+ // A picture or video opened from a bubble pages through the whole chat;
267
+ // anything not in the loaded history keeps what it was opened with.
268
+ const file = useMemo(() => {
269
+ if (!activeFile?.fileURL || !isMedia(activeFile.mimetype)) {return activeFile;}
270
+ return (
271
+ withChatSiblings(activeFile, messages, {
272
+ me: user,
273
+ fileToken,
274
+ interactive: !config?.disableInteractions,
275
+ repliesOn: !config?.disableReplies,
276
+ }) ?? activeFile
277
+ );
278
+ }, [activeFile, messages, user, fileToken, config?.disableInteractions, config?.disableReplies]);
279
+
280
+ // On the oldest loaded file, ask for an older page: what it brings in
281
+ // shows up to the left of it.
282
+ const atOldest = !!file?.siblings && file.index === 0 && !!roomJid;
283
+ const historyComplete = !!room?.historyComplete;
284
+ useEffect(() => {
285
+ if (!atOldest || historyComplete || !roomJid) {return;}
286
+ loadRoomHistory({
287
+ client: getGlobalXmppClient(),
288
+ room: store.getState().rooms.rooms?.[roomJid],
289
+ inFlight: historyInFlight,
290
+ chatJID: roomJid,
291
+ max: HISTORY_PAGE_SIZE,
292
+ });
293
+ }, [atOldest, historyComplete, roomJid, store]);
294
+
295
+ if (!file?.fileURL) {return null;}
296
+
297
+ const closeModal = () => {
298
+ dispatch(
299
+ setActiveFile({
300
+ fileName: '',
301
+ fileURL: '',
302
+ mimetype: '',
303
+ })
304
+ );
305
+ handleCloseModal?.();
306
+ };
307
+
308
+ return (
309
+ <FilePreview
310
+ file={file}
311
+ onClose={closeModal}
312
+ onChangeFile={(next) => dispatch(setActiveFile(next))}
313
+ />
314
+ );
315
+ };
316
+
317
+ interface FilePreviewProps {
318
+ file: ModalFile;
319
+ onClose: () => void;
320
+ /** Another file of the album (siblings and index included). */
321
+ onChangeFile: (file: ModalFile) => void;
322
+ /** After the media viewer's Reply opened a thread. */
323
+ onReplyOpened?: () => void;
324
+ }
325
+
326
+ /**
327
+ * One file, full screen: pictures and videos in the Telegram-style
328
+ * MediaViewer, everything else (documents, PDFs, voice notes) on the
329
+ * plain preview page with the save button in its header.
330
+ */
331
+ export const FilePreview: React.FC<FilePreviewProps> = ({
332
+ file: activeFile,
333
+ onClose,
334
+ onChangeFile,
335
+ onReplyOpened,
336
+ }) => {
254
337
  const t = useT();
255
338
  const { config } = useChatSettingState();
256
339
  const theme = useTheme();
@@ -258,16 +341,6 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
258
341
  // dark sits them on the secondary surface.
259
342
  const cardBackground = theme.dark ? theme.surfaceSecondary : '#FFF8ED';
260
343
 
261
- // Cache the SAF directory the user granted so saving several documents
262
- // in a row doesn't re-prompt for a folder every time (Android only).
263
- const safDirUriRef = useRef<string | null>(null);
264
-
265
- const { activeFile } = useSelector(
266
- (state: RootState) => state.chatSettingStore
267
- );
268
-
269
- if (!activeFile) {return null;}
270
-
271
344
  // Bug #40: prefer the sender's original name over the server's stored
272
345
  // hash name everywhere this modal shows or saves/shares the file.
273
346
  // Sanitized so it's safe to use as an actual path segment (strips path
@@ -281,154 +354,8 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
281
354
  })
282
355
  );
283
356
 
284
- const requestStoragePermission = async () => {
285
- const MediaLibrary = getMediaLibrary();
286
- if (!MediaLibrary?.requestPermissionsAsync) {return false;}
287
- try {
288
- const { status } = await MediaLibrary.requestPermissionsAsync();
289
- return status === 'granted';
290
- } catch (error) {
291
- console.error('Error requesting permission:', error);
292
- return false;
293
- }
294
- };
295
-
296
- const saveToGallery = async () => {
297
- const hasPermission = await requestStoragePermission();
298
- if (!hasPermission) {
299
- Alert.alert(
300
- 'Permission Denied',
301
- 'Storage permission is required to save files to the gallery.'
302
- );
303
- return;
304
- }
305
-
306
- try {
307
- const filePath = FileSystem.cacheDirectory + displayFileName;
308
- const download = await fetchToCache(activeFile.fileURL, filePath);
309
-
310
- if (download.status === 200) {
311
- await getMediaLibrary()?.saveToLibraryAsync(download.uri);
312
- showToast({
313
- id: Date.now().toString(),
314
- title: t('toast.successTitle'),
315
- message: t('toast.saveSuccessful'),
316
- type: 'success',
317
- });
318
- } else {
319
- Alert.alert('Error', 'Failed to save the file.');
320
- }
321
- } catch (err) {
322
- Alert.alert('Error', `Failed to save the file: ${displayFileName}`);
323
- }
324
- };
325
-
326
- // Best-effort existing-name lookup for the Android SAF branch below: SAF
327
- // hands back content URIs, not plain names, so this decodes each one and
328
- // takes its last path segment. If the provider's URI shape doesn't match
329
- // what we expect, we fail open (empty set) rather than block the save -
330
- // worst case we skip de-duplication, we never crash the save flow.
331
- const listSafDirectoryNames = async (
332
- saf: any,
333
- dirUri: string
334
- ): Promise<Set<string>> => {
335
- try {
336
- const uris: string[] = await saf.readDirectoryAsync(dirUri);
337
- const names = uris.map((uri) => {
338
- try {
339
- const decoded = decodeURIComponent(uri);
340
- const last = decoded.split('/').pop() || '';
341
- return last.includes(':') ? last.split(':').pop() || '' : last;
342
- } catch {
343
- return '';
344
- }
345
- });
346
- return new Set(names.filter(Boolean));
347
- } catch {
348
- return new Set<string>();
349
- }
350
- };
351
-
352
- const saveFileToDownloads = async () => {
353
- try {
354
- const filePath = FileSystem.cacheDirectory + displayFileName;
355
- const download = await fetchToCache(activeFile.fileURL, filePath);
356
-
357
- if (download.status !== 200) {
358
- Alert.alert('Error', 'Failed to save the file.');
359
- return;
360
- }
361
-
362
- if (Platform.OS === 'android') {
363
- const saf = (FileSystem as any).StorageAccessFramework;
364
- let dirUri = safDirUriRef.current;
365
- if (!dirUri) {
366
- const perm = await saf.requestDirectoryPermissionsAsync();
367
- if (!perm.granted) {
368
- Alert.alert(
369
- 'Permission Denied',
370
- 'Storage permission is required to save the file.'
371
- );
372
- return;
373
- }
374
- dirUri = perm.directoryUri;
375
- safDirUriRef.current = dirUri;
376
- }
377
-
378
- // Avoid silently overwriting a different attachment that happens
379
- // to share the same display name (common now that we prefer the
380
- // human-picked name over the unique server hash - bug #40) by
381
- // suffixing " (1)", " (2)", ... on a collision.
382
- const existingNames = await listSafDirectoryNames(saf, dirUri as string);
383
- const uniqueName = await getUniqueFileName(
384
- displayFileName,
385
- async (candidate) => existingNames.has(candidate)
386
- );
387
- const baseName = uniqueName.replace(/\.[^/.]+$/, '');
388
- const mime = activeFile.mimetype || 'application/octet-stream';
389
- const base64 = await FileSystem.readAsStringAsync(download.uri, {
390
- encoding: FileSystem.EncodingType.Base64,
391
- });
392
- const destUri = await saf.createFileAsync(dirUri, baseName, mime);
393
- await FileSystem.writeAsStringAsync(destUri, base64, {
394
- encoding: FileSystem.EncodingType.Base64,
395
- });
396
-
397
- showToast({
398
- id: Date.now().toString(),
399
- title: t('toast.successTitle'),
400
- message: t('toast.saveSuccessful'),
401
- type: 'success',
402
- });
403
- return;
404
- }
405
-
406
- // iOS (and any non-Android): there is no app-writable "Downloads"
407
- // folder, and MediaLibrary only accepts images/videos — which is why
408
- // documents previously hit the success toast without ever being
409
- // saved. Present the system share sheet so the user can "Save to
410
- // Files", AirDrop, etc. The sheet itself is the confirmation (the
411
- // user may cancel), so we don't show a "Save successful" toast here.
412
- await Share.share({
413
- url: download.uri,
414
- title: displayFileName,
415
- });
416
- } catch (err) {
417
- console.error('Error saving file:', err);
418
- Alert.alert('Error', 'Failed to save the file.');
419
- }
420
- };
421
-
422
- const saveClick = async () => {
423
- if (
424
- activeFile.mimetype.startsWith('image/') ||
425
- activeFile.mimetype.startsWith('video/')
426
- ) {
427
- await saveToGallery();
428
- } else {
429
- await saveFileToDownloads();
430
- }
431
- };
357
+ const saveFile = useSaveFile();
358
+ const saveClick = () => saveFile(activeFile);
432
359
 
433
360
  // An album opens with its siblings; the viewer pages through them.
434
361
  const albumSize = activeFile.siblings?.length ?? 0;
@@ -436,21 +363,24 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
436
363
  const goToSibling = (next: number) => {
437
364
  const target = activeFile.siblings?.[next];
438
365
  if (!target) {return;}
439
- dispatch(
440
- setActiveFile({ ...target, siblings: activeFile.siblings, index: next })
441
- );
366
+ onChangeFile({ ...target, siblings: activeFile.siblings, index: next });
442
367
  };
368
+ const closeModal = onClose;
443
369
 
444
- const closeModal = () => {
445
- dispatch(
446
- setActiveFile({
447
- fileName: '',
448
- fileURL: '',
449
- mimetype: '',
450
- })
370
+ if (
371
+ activeFile.mimetype.startsWith('image/') ||
372
+ activeFile.mimetype.startsWith('video/')
373
+ ) {
374
+ return (
375
+ <MediaViewer
376
+ file={activeFile}
377
+ onClose={onClose}
378
+ onPage={goToSibling}
379
+ onSave={saveClick}
380
+ onReplyOpened={onReplyOpened}
381
+ />
451
382
  );
452
- handleCloseModal?.();
453
- };
383
+ }
454
384
 
455
385
  // NOTE: plain computed value, NOT useMemo. It sits AFTER the
456
386
  // `if (!activeFile) return` guard above, so as a hook it would run