mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-06-08 07:37:12 -04:00
Open email attachments in inline mobile app viewer when available (#2071)
This commit is contained in:
committed by
Leendert de Borst
parent
7baf27038c
commit
006edc018c
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
apps/mobile-app/hooks/useAttachmentViewer.tsx
Normal file
122
apps/mobile-app/hooks/useAttachmentViewer.tsx
Normal 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 };
|
||||
};
|
||||
10
apps/mobile-app/package-lock.json
generated
10
apps/mobile-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user