diff --git a/apps/mobile-app/app/(tabs)/emails/[id].tsx b/apps/mobile-app/app/(tabs)/emails/[id].tsx index a13164a8a..f21f4e7f2 100644 --- a/apps/mobile-app/app/(tabs)/emails/[id].tsx +++ b/apps/mobile-app/app/(tabs)/emails/[id].tsx @@ -3,7 +3,6 @@ import { Buffer } from 'buffer'; import { Ionicons } from '@expo/vector-icons'; import * as FileSystem from 'expo-file-system'; import { useLocalSearchParams, useRouter, useNavigation, Stack } from 'expo-router'; -import * as Sharing from 'expo-sharing'; import React, { useEffect, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, ActivityIndicator, useColorScheme, Linking, Text, TextInput, Platform } from 'react-native'; @@ -15,6 +14,7 @@ import type { Email } from '@/utils/dist/core/models/webapi'; import EncryptionUtility from '@/utils/EncryptionUtility'; import emitter from '@/utils/EventEmitter'; +import { useAttachmentViewer } from '@/hooks/useAttachmentViewer'; import { useColors } from '@/hooks/useColorScheme'; import { ConfirmDialog } from '@/components/common/ConfirmDialog'; @@ -37,6 +37,7 @@ export default function EmailDetailsScreen() : React.ReactNode { const webApi = useWebApi(); const colors = useColors(); const { t } = useTranslation(); + const { openAttachment, viewerElement } = useAttachmentViewer(); const [error, setError] = useState(null); const [email, setEmail] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -171,17 +172,15 @@ export default function EmailDetailsScreen() : React.ReactNode { encoding: FileSystem.EncodingType.Base64, }); - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(tempFile, { - mimeType: attachment.mimeType || 'application/octet-stream', - dialogTitle: attachment.filename, + try { + await openAttachment({ + filePath: tempFile, + fileName: attachment.filename, + mimeType: attachment.mimeType, }); - } else { - setError('Sharing is not available on this device'); + } finally { + await FileSystem.deleteAsync(tempFile, { idempotent: true }); } - - // Cleanup after sharing completes - await FileSystem.deleteAsync(tempFile); } catch (err) { console.error('handleDownloadAttachment error', err); setError(err instanceof Error ? err.message : t('common.errors.unknownError')); @@ -548,6 +547,8 @@ export default function EmailDetailsScreen() : React.ReactNode { )} + {viewerElement} + = ({ item }): React.ReactNode => { const [attachments, setAttachments] = useState([]); - const [previewModalVisible, setPreviewModalVisible] = useState(false); - const [selectedFile, setSelectedFile] = useState<{ - name: string; - path: string; - extension: string; - } | null>(null); const colors = useColors(); const dbContext = useDb(); const { t } = useTranslation(); const { showAlert } = useDialog(); + const { openAttachment, viewerElement } = useAttachmentViewer(); /** * Handle attachment action - preview or download. @@ -46,7 +39,6 @@ export const AttachmentSection: React.FC = ({ item }): R try { // Sanitize filename const sanitizedFilename = attachment.Filename.replace(/[/\\]/g, '_'); - const fileExtension = sanitizedFilename.split('.').pop()?.toLowerCase() ?? ''; const downloadsDir = FileSystem.documentDirectory + 'Downloads/'; const filePath = downloadsDir + sanitizedFilename; @@ -73,102 +65,13 @@ export const AttachmentSection: React.FC = ({ item }): R }); } - // Define supported file types - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; - const textExtensions = ['txt', 'md', 'json', 'csv', 'log', 'xml', 'js', 'ts', 'tsx', 'jsx', 'html', 'css']; - const previewableExtensions = [...imageExtensions, ...textExtensions]; - - if (previewableExtensions.includes(fileExtension)) { - // Show preview modal for supported file types - setSelectedFile({ - name: sanitizedFilename, - path: filePath, - extension: fileExtension, - }); - setPreviewModalVisible(true); - } else { - // For other file types, offer download directly - await downloadFileToSystem(filePath, sanitizedFilename); - } + await openAttachment({ filePath, fileName: sanitizedFilename }); } catch (error) { console.error('Error handling attachment:', error); showAlert('Error', 'Failed to process attachment'); } }; - /** - * Download file to system (iOS Files app or Android file manager). - */ - const downloadFileToSystem = async (filePath: string, filename: string): Promise => { - try { - const canShare = await Sharing.isAvailableAsync(); - if (canShare) { - // Use Sharing API to trigger system save dialog - await Sharing.shareAsync(filePath, { - dialogTitle: `Save ${filename}`, - mimeType: getMimeType(filename), - UTI: getUTI(filename), - }); - } else { - showAlert(t('common.success'), `${t('items.fileSavedTo')}: ${filePath}`); - } - } catch (error) { - console.error('Error downloading file:', error); - showAlert(t('common.error'), t('common.errors.unknownError')); - } - }; - - /** - * Get MIME type for file. - */ - const getMimeType = (filename: string): string => { - const extension = filename.split('.').pop()?.toLowerCase() ?? ''; - const mimeTypes: Record = { - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'xml': 'application/xml', - 'json': 'application/json', - 'zip': 'application/zip', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'bmp': 'image/bmp', - 'webp': 'image/webp', - }; - return mimeTypes[extension] ?? 'application/octet-stream'; - }; - - /** - * Get UTI (Uniform Type Identifier) for iOS. - */ - const getUTI = (filename: string): string => { - const extension = filename.split('.').pop()?.toLowerCase() ?? ''; - const utis: Record = { - 'pdf': 'com.adobe.pdf', - 'doc': 'com.microsoft.word.doc', - 'docx': 'org.openxmlformats.wordprocessingml.document', - 'xls': 'com.microsoft.excel.xls', - 'xlsx': 'org.openxmlformats.spreadsheetml.sheet', - 'txt': 'public.plain-text', - 'csv': 'public.comma-separated-values-text', - 'xml': 'public.xml', - 'json': 'public.json', - 'zip': 'public.zip-archive', - 'jpg': 'public.jpeg', - 'jpeg': 'public.jpeg', - 'png': 'public.png', - 'gif': 'com.compuserve.gif', - }; - return utis[extension] ?? 'public.data'; - }; /** * Load the attachments. */ @@ -265,19 +168,7 @@ export const AttachmentSection: React.FC = ({ item }): R ))} - - {selectedFile && ( - { - setPreviewModalVisible(false); - setSelectedFile(null); - }} - fileName={selectedFile.name} - filePath={selectedFile.path} - fileExtension={selectedFile.extension} - /> - )} + {viewerElement} ); }; diff --git a/apps/mobile-app/components/items/details/EmailPreview.tsx b/apps/mobile-app/components/items/details/EmailPreview.tsx index 04bed54c2..590ef71e6 100644 --- a/apps/mobile-app/components/items/details/EmailPreview.tsx +++ b/apps/mobile-app/components/items/details/EmailPreview.tsx @@ -291,11 +291,20 @@ export const EmailPreview: React.FC = ({ email }) : React.Rea section: { paddingTop: 16, }, + attachmentIcon: { + flexShrink: 0, + marginLeft: 6, + }, subject: { color: colors.text, + flexShrink: 1, fontSize: 16, fontWeight: 'bold', }, + subjectRow: { + alignItems: 'center', + flexDirection: 'row', + }, title: { color: colors.text, fontSize: 20, @@ -389,9 +398,19 @@ export const EmailPreview: React.FC = ({ email }) : React.Rea } }} > - - {mail.subject} - + + + {mail.subject} + + {mail.hasAttachments && ( + + )} + {new Date(mail.dateSystem).toLocaleDateString()} diff --git a/apps/mobile-app/hooks/useAttachmentViewer.tsx b/apps/mobile-app/hooks/useAttachmentViewer.tsx new file mode 100644 index 000000000..5cf2264f8 --- /dev/null +++ b/apps/mobile-app/hooks/useAttachmentViewer.tsx @@ -0,0 +1,122 @@ +import * as FileSystem from 'expo-file-system'; +import * as IntentLauncher from 'expo-intent-launcher'; +import * as Sharing from 'expo-sharing'; +import React, { useCallback, useRef, useState } from 'react'; +import { Platform } from 'react-native'; + +import { FilePreviewModal } from '@/components/common/FilePreviewModal'; + +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; +const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'csv', 'log', 'xml', 'js', 'ts', 'tsx', 'jsx', 'html', 'css']; +const PREVIEWABLE_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...TEXT_EXTENSIONS]); + +const MIME_TYPES: Record = { + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + txt: 'text/plain', + csv: 'text/csv', + xml: 'application/xml', + json: 'application/json', + zip: 'application/zip', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + bmp: 'image/bmp', + webp: 'image/webp', +}; + +const getExtension = (fileName: string): string => fileName.split('.').pop()?.toLowerCase() ?? ''; + +const resolveMimeType = (fileName: string, explicit?: string): string => { + if (explicit && explicit !== 'application/octet-stream') { + return explicit; + } + return MIME_TYPES[getExtension(fileName)] ?? explicit ?? 'application/octet-stream'; +}; + +export type OpenAttachmentInput = { + /** Absolute path to the decrypted file on disk. */ + filePath: string; + /** Display name (with extension) used for previews and share dialogs. */ + fileName: string; + /** Optional MIME type hint (e.g. from the server). Falls back to extension-based lookup. */ + mimeType?: string; +}; + +type ModalState = { + filePath: string; + fileName: string; + fileExtension: string; + resolve: () => void; +}; + +/** + * Hook that opens an attachment using the best-available viewer: + * 1. In-app preview for images and text files. + * 2. Android: ACTION_VIEW intent so the OS picks the default viewer (Photos, PDF reader, etc.). + * 3. Fallback: share sheet (iOS for everything non-previewable, Android when no viewer is registered). + */ +export const useAttachmentViewer = (): { + openAttachment: (input: OpenAttachmentInput) => Promise; + viewerElement: React.ReactNode; +} => { + const [modalState, setModalState] = useState(null); + const modalResolveRef = useRef<(() => void) | null>(null); + + const closeModal = useCallback((): void => { + setModalState(null); + modalResolveRef.current?.(); + modalResolveRef.current = null; + }, []); + + const openAttachment = useCallback(async ({ filePath, fileName, mimeType }: OpenAttachmentInput): Promise => { + const extension = getExtension(fileName); + const resolvedMime = resolveMimeType(fileName, mimeType); + + if (PREVIEWABLE_EXTENSIONS.has(extension)) { + return new Promise((resolve) => { + modalResolveRef.current = resolve; + setModalState({ filePath, fileName, fileExtension: extension, resolve }); + }); + } + + if (Platform.OS === 'android') { + try { + const contentUri = await FileSystem.getContentUriAsync(filePath); + await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { + data: contentUri, + flags: 1, // FLAG_GRANT_READ_URI_PERMISSION + type: resolvedMime, + }); + return; + } catch { + // No app registered for this MIME type; fall through to the share sheet. + } + } + + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(filePath, { + mimeType: resolvedMime, + dialogTitle: fileName, + }); + } + }, []); + + const viewerElement = modalState ? ( + + ) : null; + + return { openAttachment, viewerElement }; +}; diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index af298e64c..8b39aadf8 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -23,6 +23,7 @@ "expo-file-system": "~18.1.11", "expo-font": "~13.3.2", "expo-haptics": "~14.1.4", + "expo-intent-launcher": "~12.1.5", "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.7", "expo-local-authentication": "~16.0.5", @@ -7868,6 +7869,15 @@ "expo": "*" } }, + "node_modules/expo-intent-launcher": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-12.1.5.tgz", + "integrity": "sha512-KmCc/dJHTnVf2ZdrZhYSkvQ588K7qQW+nBGfJj5woCwhEXwYz1xOLQcShnPQgQWRf8conAvQDkI3pbjYNPcECw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "14.1.4", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz", diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index 923429399..19d260033 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -73,7 +73,8 @@ "react-native-toast-message": "^2.2.1", "react-native-webview": "13.13.5", "sanitize-html": "^2.17.0", - "yup": "^1.6.1" + "yup": "^1.6.1", + "expo-intent-launcher": "~12.1.5" }, "devDependencies": { "@babel/core": "^7.25.2",