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
+4
View File
@@ -248,10 +248,14 @@
"invalid_name": "Invalid name",
"invalid_name_slash": "Names cannot contain \"/\".",
"move_to": "Move to…",
"open_error": "Could not open the file",
"open_with": "Open with…",
"preview_too_large": "This file is too large to preview.",
"selected_count": "{count} selected",
"share_add_people": "Add people",
"share_empty": "Not shared with anyone yet",
"share_error": "Failed to update sharing",
"share_file_error": "Could not share the file",
"share_folder_hint": "Sharing a folder shares everything inside it.",
"share_no_matches": "No matches",
"share_no_users": "No other users found",
+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'),
)}
/>
);
}
+177 -3
View File
@@ -14,6 +14,16 @@ vi.mock('react-native', () => ({
},
}));
// What the mocked file system was asked to do: deleted and copied paths, and
// directory listings (by directory uri) for the stale-file sweep.
const { fsState } = vi.hoisted(() => ({
fsState: {
deleted: [] as string[],
copies: [] as Array<[string, string]>,
listings: new Map<string, Array<{ uri: string }>>(),
},
}));
// A `File` that records both URL forms, so tests can assert which one each
// handoff path was given: expo-sharing needs `file://`, an Intent needs
// `content://`.
@@ -23,24 +33,34 @@ vi.mock('expo-file-system', () => {
uri: string;
contentUri: string;
exists = true;
modificationTime: number | null = null;
private parent: { uri: string };
constructor(dir: { uri: string }, name: string) {
this.parent = dir;
this.name = name;
this.uri = `${dir.uri}${name}`;
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
}
get parentDirectory() { return this.parent; }
create() {}
delete() {}
delete() { fsState.deleted.push(this.uri); }
write() {}
copy(dest: { uri: string }) { fsState.copies.push([this.uri, dest.uri]); }
static downloadFileAsync = vi.fn(async () => undefined);
}
class Directory {
uri: string;
name: string;
exists = true;
constructor(parent: { uri: string }, name: string) {
this.name = name;
this.uri = `${parent.uri}${name}/`;
}
create() {}
list() { return []; }
delete() { fsState.deleted.push(this.uri); }
list() {
return (fsState.listings.get(this.uri) ?? []).filter((e) => !fsState.deleted.includes(e.uri));
}
}
return {
File,
@@ -81,7 +101,12 @@ vi.mock('../../api/blob', () => ({
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { shareAttachment, downloadAttachment } from '../email-export';
import { File, Directory } from 'expo-file-system';
import { getDownloadUrl } from '../../api/blob';
import {
shareAttachment, downloadAttachment, cachePreviewFile, discardPreviewFile, saveLocalFileCopy,
shareLocalFile,
} from '../email-export';
const VIEW = 'android.intent.action.VIEW';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
@@ -183,3 +208,152 @@ describe('downloadAttachment (save a copy)', () => {
});
});
});
describe('cachePreviewFile (in-app preview)', () => {
beforeEach(() => {
fsState.deleted.length = 0;
vi.mocked(File.downloadFileAsync).mockClear();
vi.mocked(getDownloadUrl).mockClear();
});
it('downloads into a folder of its own, keeping the real name and the owning account', async () => {
const file = await cachePreviewFile('blob-p', 'report.pdf', 'application/pdf', 'acct-2');
expect(getDownloadUrl).toHaveBeenCalledWith('blob-p', 'report.pdf', 'application/pdf', 'acct-2');
expect(file.uri).toMatch(/^file:\/\/\/cache\/bulwark-exports\/preview-[^/]+\/report\.pdf$/);
expect(File.downloadFileAsync).toHaveBeenCalledWith(
'https://mail.example.com/blob-p/report.pdf',
file,
{ headers: { Authorization: 'Bearer token' }, idempotent: true },
);
});
it('never reuses a path, so two files with one name do not share an image cache entry', async () => {
const a = await cachePreviewFile('blob-a', 'photo.jpg', 'image/jpeg');
const b = await cachePreviewFile('blob-b', 'photo.jpg', 'image/jpeg');
expect(a.name).toBe('photo.jpg');
expect(b.name).toBe('photo.jpg');
expect(a.uri).not.toBe(b.uri);
});
it('makes the name safe for the file system', async () => {
const file = await cachePreviewFile('blob-n', 'a:b?.txt', 'text/plain');
expect(file.name).toBe('a_b_.txt');
});
it('removes its folder and rethrows when the download fails', async () => {
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
await expect(cachePreviewFile('blob-x', 'gone.txt', 'text/plain')).rejects.toThrow('404');
expect(fsState.deleted).toHaveLength(1);
expect(fsState.deleted[0]).toMatch(/\/bulwark-exports\/preview-[^/]+\/$/);
});
});
describe('discardPreviewFile', () => {
beforeEach(() => {
fsState.deleted.length = 0;
});
it('deletes the preview file together with its folder', async () => {
const file = await cachePreviewFile('blob-d', 'notes.txt', 'text/plain');
discardPreviewFile(file);
expect(fsState.deleted).toEqual([file.uri.replace(/notes\.txt$/, '')]);
});
it('only deletes the file itself outside a preview folder', () => {
const exports = new Directory({ uri: 'file:///cache/' } as never, 'bulwark-exports');
const file = new File(exports as never, 'shared.zip');
discardPreviewFile(file);
expect(fsState.deleted).toEqual(['file:///cache/bulwark-exports/shared.zip']);
});
});
describe('saveLocalFileCopy (Download from a preview)', () => {
beforeEach(() => {
platform.OS = 'android';
fsState.copies.length = 0;
vi.mocked(File.downloadFileAsync).mockClear();
vi.mocked(IntentLauncher.startActivityAsync).mockClear();
vi.mocked(Sharing.shareAsync).mockClear();
});
it('copies the file into documents and offers the share sheet without downloading again', async () => {
const preview = await cachePreviewFile('blob-s', 'report.pdf', 'application/pdf');
vi.mocked(File.downloadFileAsync).mockClear();
await saveLocalFileCopy(preview, 'application/pdf');
expect(fsState.copies).toEqual([[preview.uri, 'file:///documents/report.pdf']]);
expect(File.downloadFileAsync).not.toHaveBeenCalled();
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
mimeType: 'application/pdf',
dialogTitle: 'report.pdf',
});
});
});
describe('shareLocalFile', () => {
beforeEach(() => {
platform.OS = 'ios';
fsState.deleted.length = 0;
vi.mocked(Sharing.shareAsync).mockClear();
});
it('removes the file after sharing by default', async () => {
const file = new File({ uri: 'file:///cache/bulwark-exports/' } as never, 'bundle.zip');
await shareLocalFile(file, 'application/zip');
expect(Sharing.shareAsync).toHaveBeenCalledTimes(1);
expect(fsState.deleted).toEqual([file.uri]);
});
it('keeps the file an open preview still shows', async () => {
const file = new File({ uri: 'file:///cache/bulwark-exports/preview-1/' } as never, 'photo.jpg');
await shareLocalFile(file, 'image/jpeg', 'photo.jpg', { forceSheet: true, keep: true });
expect(Sharing.shareAsync).toHaveBeenCalledTimes(1);
expect(fsState.deleted).toEqual([]);
});
});
// Last: it reloads the module to get a process that has not swept yet.
describe('sweepStaleExportFiles', () => {
it('clears stale files, and preview folders once they are empty', async () => {
vi.resetModules();
const fsMod = await import('expo-file-system');
const { sweepStaleExportFiles } = await import('../email-export');
fsState.deleted.length = 0;
fsState.listings.clear();
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const root = new fsMod.Directory({ uri: 'file:///cache/' } as never, 'bulwark-exports');
const aged = (dir: { uri: string }, name: string, age: number) => {
const f = new fsMod.File(dir as never, name);
(f as unknown as { modificationTime: number }).modificationTime = now - age;
return f;
};
const oldExport = aged(root, 'old.pdf', 2 * day);
const newExport = aged(root, 'new.pdf', 60_000);
const handedOff = new fsMod.Directory(root as never, 'preview-a');
const open = new fsMod.Directory(root as never, 'preview-b');
const other = new fsMod.Directory(root as never, 'keep-me');
fsState.listings.set(root.uri, [oldExport, newExport, handedOff, open, other]);
fsState.listings.set(handedOff.uri, [aged(handedOff, 'photo.jpg', 2 * day)]);
fsState.listings.set(open.uri, [aged(open, 'notes.txt', 60_000)]);
fsState.listings.set(other.uri, [aged(other, 'x.bin', 2 * day)]);
await sweepStaleExportFiles();
expect(fsState.deleted).toEqual([
oldExport.uri,
`${handedOff.uri}photo.jpg`,
handedOff.uri,
]);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import {
filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES,
} from '../file-preview';
const IOS = { inlinePdf: true };
const ANDROID = { inlinePdf: false };
describe('filePreviewKind', () => {
it('previews the images React Native decodes, by type or by extension', () => {
expect(filePreviewKind({ name: 'photo.jpg', type: 'image/jpeg' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'scan.PNG', type: 'application/octet-stream' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'IMG_0001', type: 'image/heic' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'anim.webp', type: '' }, ANDROID)).toBe('image');
});
it('hands images it cannot decode to another app', () => {
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'raw.cr2', type: 'image/x-canon-cr2' }, ANDROID)).toBe('none');
});
it('keeps script-bearing documents out of the preview', () => {
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('none');
});
it('shows PDFs in-app only where the platform renders them', () => {
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
});
it('shows plain text, markdown and data files as text', () => {
expect(filePreviewKind({ name: 'notes.txt', type: 'text/plain' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'data.csv', type: 'text/csv' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'config', type: 'application/json' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'feed', type: 'application/atom+xml' }, ANDROID)).toBe('text');
});
it('shows source code as text, including the script types the reader leaves out', () => {
expect(filePreviewKind({ name: 'app.ts', type: 'application/octet-stream' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'main.py', type: '' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'build.sh', type: 'application/x-sh' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'index.js', type: 'text/javascript' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'bundle', type: 'application/javascript' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'Dockerfile', type: '' }, ANDROID)).toBe('text');
});
it('shows saved messages like the reader does', () => {
expect(filePreviewKind({ name: 'bounce.eml', type: 'application/octet-stream' }, ANDROID)).toBe('eml');
expect(filePreviewKind({ name: 'fwd', type: 'message/rfc822' }, ANDROID)).toBe('eml');
});
it('leaves binaries and office documents to other apps', () => {
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'movie.mp4', type: 'video/mp4' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
});
it('ignores MIME parameters and case', () => {
expect(filePreviewKind({ name: 'x', type: 'Text/Plain; charset=utf-8' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'x', type: 'IMAGE/PNG' }, ANDROID)).toBe('image');
});
});
describe('preview size limits', () => {
it('caps text far below images and PDFs', () => {
expect(FILE_PREVIEW_MAX_BYTES.text).toBe(512 * 1024);
expect(FILE_PREVIEW_MAX_BYTES.text).toBeLessThan(FILE_PREVIEW_MAX_BYTES.image);
expect(FILE_PREVIEW_MAX_BYTES.image).toBeLessThan(FILE_PREVIEW_MAX_BYTES.pdf);
});
it('flags only known sizes over the cap', () => {
expect(exceedsPreviewLimit('text', FILE_PREVIEW_MAX_BYTES.text)).toBe(false);
expect(exceedsPreviewLimit('text', FILE_PREVIEW_MAX_BYTES.text + 1)).toBe(true);
expect(exceedsPreviewLimit('text', undefined)).toBe(false);
expect(exceedsPreviewLimit('text', null)).toBe(false);
expect(exceedsPreviewLimit('none', 10 ** 12)).toBe(false);
});
it('opens files over the cap in another app instead', () => {
const big = { name: 'server.log', type: 'text/plain', size: 5 * 1024 * 1024 };
expect(inAppPreviewKind(big, ANDROID)).toBe('none');
expect(inAppPreviewKind({ ...big, size: 20 * 1024 }, ANDROID)).toBe('text');
expect(inAppPreviewKind({ ...big, size: undefined }, ANDROID)).toBe('text');
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 40 * 1024 * 1024 }, ANDROID)).toBe('none');
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 4 * 1024 * 1024 }, ANDROID)).toBe('image');
});
});
+90 -11
View File
@@ -73,12 +73,24 @@ function deleteQuietly(file: File): void {
} catch { /* already gone or still in use - the sweep gets it later */ }
}
// Folders the in-app preview downloads into (see cachePreviewFile).
const PREVIEW_DIR_PREFIX = 'preview-';
function deleteStaleFiles(dir: Directory, now: number, maxAgeMs: number): void {
for (const entry of dir.list()) {
if (!(entry instanceof File)) continue;
const modified = (entry as { modificationTime?: number | null }).modificationTime;
const age = typeof modified === 'number' ? now - modified : Number.POSITIVE_INFINITY;
if (age > maxAgeMs) deleteQuietly(entry);
}
}
/**
* Remove temp files older than a day. Files shared into other apps could not
* always be deleted right after the share sheet closed (the receiving app may
* still be reading them), so anything that slipped through is collected here.
* Runs once per process, lazily before the first export, and is also safe to
* call at launch.
* still be reading them), so anything that slipped through is collected here,
* including preview folders whose file went to another app. Runs once per
* process, lazily before the first export, and is also safe to call at launch.
*/
let sweptThisProcess = false;
export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise<void> {
@@ -88,11 +100,13 @@ export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise
const dir = exportsDir();
if (!dir.exists) return;
const now = Date.now();
deleteStaleFiles(dir, now, maxAgeMs);
for (const entry of dir.list()) {
if (!(entry instanceof File)) continue;
const modified = (entry as { modificationTime?: number | null }).modificationTime;
const age = typeof modified === 'number' ? now - modified : Number.POSITIVE_INFINITY;
if (age > maxAgeMs) deleteQuietly(entry);
if (!(entry instanceof Directory) || !entry.name.startsWith(PREVIEW_DIR_PREFIX)) continue;
try {
deleteStaleFiles(entry, now, maxAgeMs);
if (entry.list().length === 0) entry.delete();
} catch { /* in use - a later launch gets it */ }
}
} catch { /* housekeeping only */ }
}
@@ -261,15 +275,78 @@ export async function downloadAttachment(
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.document);
await offerSavedFile(downloaded, filename, mimeType);
}
async function offerSavedFile(file: File, filename: string, mimeType: string): Promise<void> {
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
await Sharing.shareAsync(file.uri, {
mimeType,
dialogTitle: filename,
});
}
/**
* {@link downloadAttachment} for a file already on the device (an open
* preview): copy it into the document directory and offer it through the
* share sheet, without downloading it again.
*/
export async function saveLocalFileCopy(file: File, mimeType: string, filename = file.name): Promise<void> {
const name = safeAttachmentName(filename, mimeType);
const dest = new File(Paths.document, name);
if (dest.exists) dest.delete();
file.copy(dest);
await offerSavedFile(dest, name, mimeType);
}
// ─── In-app preview files ───────────────────────────────────────────────
let previewSeq = 0;
/**
* Download a blob for the in-app preview. Every preview gets its own folder
* in the exports cache, so the file keeps its real name for Share and Open
* with while its path stays unique: React Native's Image caches decoded
* images by URI, and two files both called "photo.jpg" must not show the same
* picture. Remove it with {@link discardPreviewFile} when the preview closes;
* once it went to another app, leave it to the stale-file sweep instead.
*/
export async function cachePreviewFile(
blobId: string,
name: string | undefined,
type: string | undefined,
// Owning account for blobs shared by another principal (Files app).
accountId?: string,
): Promise<File> {
void sweepStaleExportFiles();
previewSeq += 1;
const dir = new Directory(exportsDir(), `${PREVIEW_DIR_PREFIX}${Date.now().toString(36)}-${previewSeq.toString(36)}`);
const filename = safeAttachmentName(name, type);
const url = getDownloadUrl(blobId, filename, type || 'application/octet-stream', accountId);
try {
return await downloadInto(url, new File(dir, filename), dir);
} catch (err) {
try {
if (dir.exists) dir.delete();
} catch { /* the sweep gets it */ }
throw err;
}
}
/** Delete a file from {@link cachePreviewFile} together with its folder. */
export function discardPreviewFile(file: File): void {
try {
const dir = file.parentDirectory;
if (dir.name.startsWith(PREVIEW_DIR_PREFIX)) {
if (dir.exists) dir.delete();
return;
}
} catch { /* fall back to the file alone */ }
deleteQuietly(file);
}
/** Write bytes into the exports cache (caller shares / previews / cleans up). */
export function writeTempFile(bytes: Uint8Array, filename: string, mimeType?: string): File {
void sweepStaleExportFiles();
@@ -285,13 +362,15 @@ export function writeTempFile(bytes: Uint8Array, filename: string, mimeType?: st
/**
* Share an already-materialised local file (zip bundles, extracted parts).
* `forceSheet` skips the Android viewer handoff and always shows the share
* sheet - the explicit "Share" action, as opposed to "Open".
* sheet - the explicit "Share" action, as opposed to "Open". `keep` leaves the
* file in place afterwards, for a caller that still shows it (an open
* preview) and removes it itself.
*/
export async function shareLocalFile(
file: File,
mimeType: string,
dialogTitle?: string,
opts: { forceSheet?: boolean } = {},
opts: { forceSheet?: boolean; keep?: boolean } = {},
): Promise<void> {
if (!opts.forceSheet && Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
if (!(await Sharing.isAvailableAsync())) {
@@ -300,7 +379,7 @@ export async function shareLocalFile(
try {
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
} finally {
scheduleTempCleanup(file);
if (!opts.keep) scheduleTempCleanup(file);
}
}
+110
View File
@@ -0,0 +1,110 @@
// Which Files-tab nodes open in the in-app preview, and how large they may be.
// Builds on the reader's attachment classification (`previewKindFor`) so both
// previews agree, and adds what a drive holds more often than mail: source
// code and config files, shown as text. Port of the webmail's
// `lib/file-preview.ts`, narrowed to what the native preview can render;
// everything else keeps going to the OS "Open with" chooser.
import { previewKindFor, type PreviewKind } from './attachment-display';
export interface FilePreviewOptions {
/**
* Whether this platform renders PDFs in-app. iOS does, in the WebView;
* Android has no built-in renderer, so a PDF there goes straight to the
* PDF viewer instead of a preview screen that can only offer that viewer.
*/
inlinePdf: boolean;
}
interface PreviewableFile {
name: string;
type?: string | null;
size?: number | null;
}
/**
* Largest file each preview kind loads. Text is rendered in one selectable
* `<Text>`, which gets slow to lay out past a few hundred KB; parsing an
* .eml and decoding an image hold the whole file in memory; PDFs are paged by
* the WebView. Larger files open in another app instead.
*/
export const FILE_PREVIEW_MAX_BYTES: Record<Exclude<PreviewKind, 'none'>, number> = {
text: 512 * 1024,
eml: 10 * 1024 * 1024,
image: 25 * 1024 * 1024,
pdf: 50 * 1024 * 1024,
};
// Script-bearing documents open in the browser, as in the reader. The webmail
// renders HTML in a sandboxed iframe and SVG through <img>; neither has a safe
// native equivalent here.
const NEVER_INLINE_MIME = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml']);
const NEVER_INLINE_EXT = /\.(html?|xht(ml)?|svgz?)$/i;
// Formats React Native's Image decodes. Other image types (TIFF, RAW, PSD,
// ICO) would render blank.
const RENDERABLE_IMAGE_MIME = new Set([
'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp',
'image/bmp', 'image/x-ms-bmp', 'image/heic', 'image/heif', 'image/avif',
]);
const RENDERABLE_IMAGE_EXT = /\.(png|jpe?g|jfif|gif|webp|bmp|heic|heif|avif)$/i;
// Source and config files the reader's list doesn't cover (it only sees mail
// attachments). The webmail's TEXT_EXTENSIONS plus the languages the Files
// tab already draws a code icon for.
const CODE_EXT = new RegExp(
'\\.('
+ 'text|tsv|toml|cfg|env|properties|sql|graphql|gql|css|scss|sass|less'
+ '|js|mjs|cjs|jsx|ts|mts|cts|tsx|vue|svelte'
+ '|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|pl|lua|r|dart|scala|gradle'
+ '|sh|bash|zsh|fish|ps1|bat|cmd'
+ '|tex|rst|adoc|org|srt|vtt'
+ ')$',
'i',
);
const CODE_FILE_NAMES = new Set(['dockerfile', 'makefile', 'readme', 'license', 'changelog', '.gitignore', '.editorconfig']);
const TEXT_MIME = new Set([
'application/json', 'application/ld+json', 'application/xml', 'application/javascript',
'application/x-javascript', 'text/javascript', 'application/typescript', 'application/x-sh',
'application/x-yaml', 'application/yaml', 'application/toml', 'application/sql', 'application/graphql',
]);
function baseMime(type?: string | null): string {
return (type || '').split(';')[0].trim().toLowerCase();
}
/** How the in-app preview renders a file, or 'none' for the OS chooser. */
export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, opts: FilePreviewOptions): PreviewKind {
const mime = baseMime(file.type);
const name = file.name || '';
if (NEVER_INLINE_MIME.has(mime) || NEVER_INLINE_EXT.test(name)) return 'none';
const kind = previewKindFor({ name, type: mime });
switch (kind) {
case 'image':
return RENDERABLE_IMAGE_MIME.has(mime) || RENDERABLE_IMAGE_EXT.test(name) ? 'image' : 'none';
case 'pdf':
return opts.inlinePdf ? 'pdf' : 'none';
case 'none':
// The reader keeps .js and friends out; shown as source text they are inert.
if (CODE_EXT.test(name) || CODE_FILE_NAMES.has(name.toLowerCase())) return 'text';
if (TEXT_MIME.has(mime) || /\+(json|xml)$/.test(mime)) return 'text';
return 'none';
default:
return kind;
}
}
/** True when a known size is over the preview cap for `kind`. Unknown sizes pass; the caller checks the download. */
export function exceedsPreviewLimit(kind: PreviewKind, size: number | null | undefined): boolean {
if (kind === 'none' || size == null) return false;
return size > FILE_PREVIEW_MAX_BYTES[kind];
}
/**
* The preview a tap on this file opens: its kind when the app can show it and
* the file is within the cap, else 'none' (hand it to another app, as before).
*/
export function inAppPreviewKind(file: PreviewableFile, opts: FilePreviewOptions): PreviewKind {
const kind = filePreviewKind(file, opts);
return exceedsPreviewLimit(kind, file.size) ? 'none' : kind;
}
+34 -9
View File
@@ -19,7 +19,7 @@ import {
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search,
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check,
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink,
} from 'lucide-react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
@@ -63,6 +63,7 @@ import { useAuthStore } from '../stores/auth-store';
import { useLocaleStore } from '../stores/locale-store';
import Dialog from '../components/Dialog';
import ShareSheet from '../components/files/ShareSheet';
import { FilePreviewModal, canPreviewInApp } from '../components/files/FilePreviewModal';
// A file row carries the display name alongside the rest of the node.
interface FileRow extends FileNode {
@@ -191,6 +192,7 @@ export default function FilesScreen() {
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
const [moveTarget, setMoveTarget] = useState<FileRow | null>(null);
const [previewTarget, setPreviewTarget] = useState<FileRow | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortSheetOpen, setSortSheetOpen] = useState(false);
@@ -252,6 +254,7 @@ export default function FilesScreen() {
setActionsTarget(null);
setShareTarget(null);
setMoveTarget(null);
setPreviewTarget(null);
setSearchQuery('');
}
loadedForAccountRef.current = activeAccountId;
@@ -338,7 +341,9 @@ export default function FilesScreen() {
});
}, []);
const previewFile = useCallback(async (row: FileRow) => {
// Hand the file to another app: the viewer for its type on Android, the
// share sheet otherwise.
const openFileWith = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
try {
@@ -350,6 +355,13 @@ export default function FilesScreen() {
}
}, [t]);
// Tap on a file: the in-app preview for the types it shows (images, text
// and code, .eml, PDFs on iOS), the other app for the rest.
const openFile = useCallback((row: FileRow) => {
if (canPreviewInApp(row)) setPreviewTarget(row);
else void openFileWith(row);
}, [openFileWith]);
const downloadFile = useCallback(async (row: FileRow) => {
if (!row.blobId) return;
setBusyId(row.id);
@@ -432,9 +444,9 @@ export default function FilesScreen() {
setSearchQuery('');
return;
}
void previewFile(row);
openFile(row);
},
[selectionMode, toggleSelect, previewFile],
[selectionMode, toggleSelect, openFile],
);
const handleRowLongPress = useCallback((row: FileRow) => {
@@ -1019,7 +1031,11 @@ export default function FilesScreen() {
onClose={() => setActionsTarget(null)}
onPreview={(r) => {
setActionsTarget(null);
void previewFile(r);
setPreviewTarget(r);
}}
onOpenWith={(r) => {
setActionsTarget(null);
void openFileWith(r);
}}
onDownload={(r) => {
setActionsTarget(null);
@@ -1067,6 +1083,8 @@ export default function FilesScreen() {
onChangeDir={(d) => setSetting('filesDefaultSortDir', d)}
/>
<FilePreviewModal file={previewTarget} onClose={() => setPreviewTarget(null)} />
<ShareSheet
node={shareTarget}
onClose={() => setShareTarget(null)}
@@ -1154,6 +1172,7 @@ interface ActionsSheetProps {
t: Translate;
onClose: () => void;
onPreview: (r: FileRow) => void;
onOpenWith: (r: FileRow) => void;
onDownload: (r: FileRow) => void;
onShare: (r: FileRow) => void;
onMove: (r: FileRow) => void;
@@ -1163,7 +1182,7 @@ interface ActionsSheetProps {
}
function ActionsSheet({
target, sharingEnabled, t, onClose, onPreview, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
target, sharingEnabled, t, onClose, onPreview, onOpenWith, onDownload, onShare, onMove, onDuplicate, onRename, onDelete,
}: ActionsSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
@@ -1190,10 +1209,16 @@ function ActionsSheet({
modified ? formatModified(modified) : null,
].filter(Boolean).join(' · ')}
</Text>
{!isDir ? (
{!isDir && canPreviewInApp(target) ? (
<Pressable style={styles.action} onPress={() => onPreview(target)}>
<Share2 size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.preview', 'Preview')} / {t('files.share', 'Share')}</Text>
<Eye size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.preview', 'Preview')}</Text>
</Pressable>
) : null}
{!isDir ? (
<Pressable style={styles.action} onPress={() => onOpenWith(target)}>
<ExternalLink size={18} color={c.text} />
<Text style={styles.actionLabel}>{t('files.open_with', 'Open with…')}</Text>
</Pressable>
) : null}
{!isDir ? (