Open email attachments in inline mobile app viewer when available (#2071)

This commit is contained in:
Leendert de Borst
2026-05-25 12:10:57 +02:00
committed by Leendert de Borst
parent 7baf27038c
commit 006edc018c
7 changed files with 171 additions and 127 deletions

View File

@@ -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<string | null>(null);
const [email, setEmail] = useState<Email | null>(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 {
)}
</ThemedView>
{viewerElement}
<ConfirmDialog
isVisible={showDeleteConfirm}
title={t('emails.deleteEmail')}

View File

@@ -2,7 +2,6 @@ import { Buffer } from 'buffer';
import { MaterialIcons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
@@ -10,6 +9,7 @@ import { View, StyleSheet, TouchableOpacity } from 'react-native';
import type { Item, Attachment } from '@/utils/dist/core/models/vault';
import emitter from '@/utils/EventEmitter';
import { useAttachmentViewer } from '@/hooks/useAttachmentViewer';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedText } from '@/components/themed/ThemedText';
@@ -17,8 +17,6 @@ import { ThemedView } from '@/components/themed/ThemedView';
import { useDb } from '@/context/DbContext';
import { useDialog } from '@/context/DialogContext';
import { FilePreviewModal } from './FilePreviewModal';
type AttachmentSectionProps = {
item: Item;
};
@@ -28,16 +26,11 @@ type AttachmentSectionProps = {
*/
export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ item }): React.ReactNode => {
const [attachments, setAttachments] = useState<Attachment[]>([]);
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<AttachmentSectionProps> = ({ 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<AttachmentSectionProps> = ({ 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<void> => {
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<string, string> = {
'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<string, string> = {
'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<AttachmentSectionProps> = ({ item }): R
</View>
</TouchableOpacity>
))}
{selectedFile && (
<FilePreviewModal
visible={previewModalVisible}
onClose={() => {
setPreviewModalVisible(false);
setSelectedFile(null);
}}
fileName={selectedFile.name}
filePath={selectedFile.path}
fileExtension={selectedFile.extension}
/>
)}
{viewerElement}
</ThemedView>
);
};

View File

@@ -291,11 +291,20 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ 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<EmailPreviewProps> = ({ email }) : React.Rea
}
}}
>
<ThemedText style={styles.subject} numberOfLines={1}>
{mail.subject}
</ThemedText>
<View style={styles.subjectRow}>
<ThemedText style={styles.subject} numberOfLines={1}>
{mail.subject}
</ThemedText>
{mail.hasAttachments && (
<MaterialIcons
name="attach-file"
size={16}
color={colors.textMuted}
style={styles.attachmentIcon}
/>
)}
</View>
<ThemedText style={styles.date}>
{new Date(mail.dateSystem).toLocaleDateString()}
</ThemedText>

View File

@@ -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<string, string> = {
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<void>;
viewerElement: React.ReactNode;
} => {
const [modalState, setModalState] = useState<ModalState | null>(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<void> => {
const extension = getExtension(fileName);
const resolvedMime = resolveMimeType(fileName, mimeType);
if (PREVIEWABLE_EXTENSIONS.has(extension)) {
return new Promise<void>((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 ? (
<FilePreviewModal
visible={true}
onClose={closeModal}
fileName={modalState.fileName}
filePath={modalState.filePath}
fileExtension={modalState.fileExtension}
/>
) : null;
return { openAttachment, viewerElement };
};

View File

@@ -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",

View File

@@ -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",