feat: preview images, text and PDFs in the Files tab

Tapping a file in the Files tab downloaded it and went straight to the
Android "Open with" chooser or the iOS share sheet. It now opens the
reader's attachment preview for the types that preview can show, and
hands only the rest to another app, as before.

- `filePreviewKind` (`src/lib/file-preview.ts`) builds on the reader's
  `previewKindFor` and adds source code and config files as text; HTML,
  SVG, images React Native cannot decode and PDFs on Android (no in-app
  renderer) still go to another app
- files over a cap per type (text 512 KB, .eml 10 MB, images 25 MB,
  PDFs 50 MB) open in another app instead; when a node has no size,
  the downloaded file is checked
- the preview downloads the file once, with the same authenticated
  download as Download, into a temp folder of its own; Open with, Share
  and Download reuse that copy, so they work without another fetch
- the folder is deleted when the preview closes, or left to the
  stale-export sweep, which now also clears preview folders, once
  another app got the file
- the per-file menu offers Preview and "Open with…" instead of the
  combined "Preview / Share"
- the shared preview gains a header Open button, an optional Download
  button, labels on its icon buttons and a fallback when an image does
  not decode; the .eml parsing moves into `emlPreviewFromBytes`, which
  the reader uses too
This commit is contained in:
Linus Rath
2026-09-24 19:28:37 +02:00
parent 1d755fc066
commit 409cdb139e
10 changed files with 771 additions and 65 deletions
+2 -11
View File
@@ -16,7 +16,7 @@ import {
import { emailExportFilename } from '../../lib/download-filename';
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { unwrapEmbeddedMessage, type ExtractedAttachment } from './use-body-override';
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
interface Props {
email: Email;
@@ -131,16 +131,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
: item.part.bytes;
if (kind === 'eml') {
const res = await unwrapEmbeddedMessage(bytes);
const { default: PostalMime } = await import('postal-mime');
const parsed = await PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' });
const from = parsed.from && 'address' in parsed.from
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
: parsed.from?.name;
setPreview({
kind, name: item.name, mimeType: item.type,
eml: { subject: parsed.subject, from, date: parsed.date, html: res.html, text: res.text },
});
setPreview({ kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) });
} else {
setPreview({ kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) });
}
+84 -27
View File
@@ -4,13 +4,14 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WebView } from 'react-native-webview';
import { X, ExternalLink, Share2 } from 'lucide-react-native';
import { X, ExternalLink, Share2, Download } from 'lucide-react-native';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import EmailBodyView from '../EmailBodyView';
import type { PreviewKind } from '../../lib/attachment-display';
import type { EmlPreview } from './use-body-override';
export interface PreviewItem {
kind: PreviewKind;
@@ -21,28 +22,40 @@ export interface PreviewItem {
/** Decoded text for text-like parts. */
text?: string;
/** Parsed embedded message for .eml previews. */
eml?: { subject?: string; from?: string; date?: string; html: string | null; text: string | null };
eml?: EmlPreview;
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
notice?: string;
}
interface Props {
item: PreviewItem | null;
loading: boolean;
/** Header title while the item is still loading. */
title?: string;
onClose: () => void;
/** Hand the file to an external app (PDF on Android, unsupported types). */
/** Hand the file to an external app (PDF on Android, unsupported types, the header's Open button). */
onOpenExternal: () => void;
onShare: () => void;
/** Save a copy; adds a Download button to the header. */
onDownload?: () => void;
}
/**
* In-app preview for images, PDFs (iOS renders them in the WebView; Android
* has no built-in PDF renderer, so it offers the external viewer), text and
* embedded messages. Anything else goes to the external viewer / share sheet.
* Used by the reader's attachment chips and the Files tab.
*/
export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal, onShare }: Props) {
export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const { width } = useWindowDimensions();
// An image the platform can't decode would stay blank; fall back to the
// external viewer instead. Keyed by URI so the next item starts clean.
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
const emlEmail = React.useMemo<Email | null>(() => {
if (!item?.eml) return null;
@@ -58,6 +71,21 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal,
};
}, [item]);
const imageFailed = item?.kind === 'image' && !!item.fileUri && failedImageUri === item.fileUri;
const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri;
// The fallback screen has its own Open button; the header adds one elsewhere.
const showsFallback = !!item && (item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed);
const renderFallback = (message: string) => (
<View style={styles.centered}>
<Text style={styles.hint}>{message}</Text>
<Pressable style={styles.button} onPress={onOpenExternal}>
<ExternalLink size={16} color={c.primaryForeground} />
<Text style={styles.buttonText}>{t('email_viewer.preview.open_external', 'Open in app')}</Text>
</Pressable>
</View>
);
const renderBody = () => {
if (loading || !item) {
return (
@@ -68,6 +96,9 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal,
}
switch (item.kind) {
case 'image':
if (imageFailed) {
return renderFallback(t('email_viewer.preview.unsupported', 'No preview for this file type.'));
}
return (
<ScrollView
style={styles.flex}
@@ -76,11 +107,17 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal,
minimumZoomScale={1}
bouncesZoom
>
<Image source={{ uri: item.fileUri }} style={{ width: width - spacing.lg * 2, height: width * 1.2 }} resizeMode="contain" />
<Image
source={{ uri: item.fileUri }}
style={{ width: width - spacing.lg * 2, height: width * 1.2 }}
resizeMode="contain"
accessibilityLabel={item.name}
onError={() => setFailedImageUri(item.fileUri ?? null)}
/>
</ScrollView>
);
case 'pdf':
if (Platform.OS === 'ios' && item.fileUri) {
if (pdfInline && item.fileUri) {
return (
<WebView
source={{ uri: item.fileUri }}
@@ -94,15 +131,7 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal,
/>
);
}
return (
<View style={styles.centered}>
<Text style={styles.hint}>{t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.')}</Text>
<Pressable style={styles.button} onPress={onOpenExternal}>
<ExternalLink size={16} color={c.primaryForeground} />
<Text style={styles.buttonText}>{t('email_viewer.preview.open_external', 'Open in app')}</Text>
</Pressable>
</View>
);
return renderFallback(t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.'));
case 'text':
return (
<ScrollView style={styles.flex} contentContainerStyle={styles.textScroll}>
@@ -126,15 +155,7 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal,
</ScrollView>
);
default:
return (
<View style={styles.centered}>
<Text style={styles.hint}>{t('email_viewer.preview.unsupported', 'No preview for this file type.')}</Text>
<Pressable style={styles.button} onPress={onOpenExternal}>
<ExternalLink size={16} color={c.primaryForeground} />
<Text style={styles.buttonText}>{t('email_viewer.preview.open_external', 'Open in app')}</Text>
</Pressable>
</View>
);
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
}
};
@@ -142,11 +163,47 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal,
<Modal visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
<Pressable
onPress={onClose}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>{item?.name ?? ''}</Text>
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!item}>
<Text style={styles.title} numberOfLines={1}>{item?.name ?? title ?? ''}</Text>
{item && !showsFallback ? (
<Pressable
onPress={onOpenExternal}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('email_viewer.preview.open_external', 'Open in app')}
>
<ExternalLink size={20} color={c.text} />
</Pressable>
) : null}
{onDownload ? (
<Pressable
onPress={onDownload}
style={styles.headerBtn}
hitSlop={8}
disabled={!item}
accessibilityRole="button"
accessibilityLabel={t('files.download', 'Download')}
>
<Download size={20} color={item ? c.text : c.textMuted} />
</Pressable>
) : null}
<Pressable
onPress={onShare}
style={styles.headerBtn}
hitSlop={8}
disabled={!item}
accessibilityRole="button"
accessibilityLabel={t('email_viewer.attachment_actions.share', 'Share')}
>
<Share2 size={20} color={item ? c.text : c.textMuted} />
</Pressable>
</View>
+33 -4
View File
@@ -1,4 +1,5 @@
import React from 'react';
import type { Email as ParsedEmail } from 'postal-mime';
import type { Attachment, Email } from '../../api/types';
import { pickEmailBody } from '../../lib/email-body';
import { fetchBlobBytes, bytesToBase64 } from '../../lib/email-export';
@@ -32,18 +33,46 @@ function toBytes(content: ArrayBuffer | Uint8Array | string): Uint8Array {
return content instanceof Uint8Array ? content : new Uint8Array(content);
}
async function parseMessage(bytes: Uint8Array): Promise<ParsedEmail> {
const { default: PostalMime } = await import('postal-mime');
return PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' });
}
/**
* Parse an embedded message/rfc822 part with postal-mime and inline its cid
* images as data: URIs (they are not JMAP blobs, so the body view can't
* fetch them).
*/
export async function unwrapEmbeddedMessage(bytes: Uint8Array): Promise<{
export async function unwrapEmbeddedMessage(bytes: Uint8Array): Promise<UnwrappedMessage> {
return unwrapParsed(await parseMessage(bytes));
}
interface UnwrappedMessage {
html: string | null;
text: string | null;
attachments: ExtractedAttachment[];
}> {
const { default: PostalMime } = await import('postal-mime');
const parsed = await PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' });
}
/** What the in-app preview shows for an .eml file or message/rfc822 part. */
export interface EmlPreview {
subject?: string;
from?: string;
date?: string;
html: string | null;
text: string | null;
}
/** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */
export async function emlPreviewFromBytes(bytes: Uint8Array): Promise<EmlPreview> {
const parsed = await parseMessage(bytes);
const { html, text } = unwrapParsed(parsed);
const from = parsed.from && 'address' in parsed.from
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
: parsed.from?.name;
return { subject: parsed.subject, from, date: parsed.date, html, text };
}
function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage {
let html = parsed.html ?? null;
const text = parsed.text ?? null;
const attachments: ExtractedAttachment[] = [];
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import type { File } from 'expo-file-system';
import type { FileNode } from '../../api/types';
import {
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import { useLocaleStore } from '../../stores/locale-store';
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
import { emlPreviewFromBytes } from '../email/use-body-override';
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
export function canPreviewInApp(node: PreviewableFileNode): boolean {
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
}
interface Props {
/** The file to show; null closes the preview. */
file: PreviewableFileNode | null;
onClose: () => void;
}
/**
* The Files tab's in-app preview: the reader's attachment preview fed from a
* FileNode. The file is downloaded once, with the same authenticated
* download as "Download" (token refresh, client certificate), into its own
* temp folder; Open with, Share and Download then all use that copy instead
* of fetching it again. The copy is deleted when the preview closes, unless
* another app got it, in which case the stale-export sweep removes it.
*/
export function FilePreviewModal({ file, onClose }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
// The downloaded copy of the open preview.
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
const busy = React.useRef(false);
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
const blobId = file?.blobId ?? null;
const accountId = file?.accountId;
const name = file?.name ?? '';
const type = file?.type || 'application/octet-stream';
const size = file?.size;
React.useEffect(() => {
if (!blobId) return undefined;
let cancelled = false;
setItem(null);
setLoading(true);
const build = async (local: File): Promise<PreviewItem> => {
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
// The node may not report a size; the download does.
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
return {
kind: 'none',
name,
mimeType: type,
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
};
}
switch (kind) {
case 'text':
return { kind, name, mimeType: type, text: await local.text() };
case 'eml':
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
default:
return { kind, name, mimeType: type, fileUri: local.uri };
}
};
void (async () => {
try {
const local = await cachePreviewFile(blobId, name, type, accountId);
if (cancelled) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await build(local);
if (!cancelled) setItem(next);
} catch (e) {
if (cancelled) return;
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
onCloseRef.current();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
const open = session.current;
session.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
setItem(null);
setLoading(false);
};
}, [blobId, accountId, name, type, size, t]);
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
if (!open || busy.current) return;
busy.current = true;
// Another app may keep reading the file after the sheet or viewer returns.
if (handsOff) open.handedOff = true;
run(open.file)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
};
return (
<AttachmentPreviewModal
item={item}
loading={loading}
title={name}
onClose={onClose}
onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
)}
onShare={() => act(
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
)}
onDownload={() => act(
(local) => saveLocalFileCopy(local, type),
false,
t('files.download_error', 'Failed to download'),
)}
/>
);
}