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 ? (