From 409cdb139e72d55bcd9f7bdddec07e910f5a5cc5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:24:59 +0200 Subject: [PATCH] feat: preview images, text and PDFs in the Files tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- locales/rn/en.json | 4 + src/components/email/AttachmentChips.tsx | 13 +- .../email/AttachmentPreviewModal.tsx | 111 ++++++++--- src/components/email/use-body-override.ts | 37 +++- src/components/files/FilePreviewModal.tsx | 144 ++++++++++++++ src/lib/__tests__/email-export.test.ts | 180 +++++++++++++++++- src/lib/__tests__/file-preview.test.ts | 93 +++++++++ src/lib/email-export.ts | 101 ++++++++-- src/lib/file-preview.ts | 110 +++++++++++ src/screens/FilesScreen.tsx | 43 ++++- 10 files changed, 771 insertions(+), 65 deletions(-) create mode 100644 src/components/files/FilePreviewModal.tsx create mode 100644 src/lib/__tests__/file-preview.test.ts create mode 100644 src/lib/file-preview.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index d6c30c6d..cafc460b 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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", diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index dc4385ba..7db67bd3 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -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) }); } diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 72864249..0c8255d9 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -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(null); const emlEmail = React.useMemo(() => { 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) => ( + + {message} + + + {t('email_viewer.preview.open_external', 'Open in app')} + + + ); + 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 ( - + setFailedImageUri(item.fileUri ?? null)} + /> ); case 'pdf': - if (Platform.OS === 'ios' && item.fileUri) { + if (pdfInline && item.fileUri) { return ( ); } - return ( - - {t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.')} - - - {t('email_viewer.preview.open_external', 'Open in app')} - - - ); + return renderFallback(t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.')); case 'text': return ( @@ -126,15 +155,7 @@ export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal, ); default: - return ( - - {t('email_viewer.preview.unsupported', 'No preview for this file type.')} - - - {t('email_viewer.preview.open_external', 'Open in app')} - - - ); + 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, - + - {item?.name ?? ''} - + {item?.name ?? title ?? ''} + {item && !showsFallback ? ( + + + + ) : null} + {onDownload ? ( + + + + ) : null} + diff --git a/src/components/email/use-body-override.ts b/src/components/email/use-body-override.ts index ca16c9fa..47bde2ae 100644 --- a/src/components/email/use-body-override.ts +++ b/src/components/email/use-body-override.ts @@ -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 { + 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 { + 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 { + 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[] = []; diff --git a/src/components/files/FilePreviewModal.tsx b/src/components/files/FilePreviewModal.tsx new file mode 100644 index 00000000..ba58b746 --- /dev/null +++ b/src/components/files/FilePreviewModal.tsx @@ -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; + +/** 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(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 => { + 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, 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 ( + 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'), + )} + /> + ); +} diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts index 7c83ffa4..3244c6e5 100644 --- a/src/lib/__tests__/email-export.test.ts +++ b/src/lib/__tests__/email-export.test.ts @@ -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>(), + }, +})); + // 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, + ]); + }); +}); diff --git a/src/lib/__tests__/file-preview.test.ts b/src/lib/__tests__/file-preview.test.ts new file mode 100644 index 00000000..a500af61 --- /dev/null +++ b/src/lib/__tests__/file-preview.test.ts @@ -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'); + }); +}); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index d8b404ee..0469192d 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -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 { @@ -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 { 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 { + 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 { + 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 { 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); } } diff --git a/src/lib/file-preview.ts b/src/lib/file-preview.ts new file mode 100644 index 00000000..b358eb3d --- /dev/null +++ b/src/lib/file-preview.ts @@ -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 + * ``, 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, 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 ; 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, 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; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 3cbcbb1b..5ecce550 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -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(null); const [shareTarget, setShareTarget] = useState(null); const [moveTarget, setMoveTarget] = useState(null); + const [previewTarget, setPreviewTarget] = useState(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)} /> + setPreviewTarget(null)} /> + 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(' · ')} - {!isDir ? ( + {!isDir && canPreviewInApp(target) ? ( onPreview(target)}> - - {t('files.preview', 'Preview')} / {t('files.share', 'Share')} + + {t('files.preview', 'Preview')} + + ) : null} + {!isDir ? ( + onOpenWith(target)}> + + {t('files.open_with', 'Open with…')} ) : null} {!isDir ? (