diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index 7db67bd3..b1cb6680 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native'; import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from 'lucide-react-native'; +import type { File } from 'expo-file-system'; import type { Attachment, Email } from '../../api/types'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; import { - shareAttachment, downloadAttachment, shareAttachmentViaSheet, cacheBlobFile, fetchBlobBytes, - shareBytes, writeTempFile, shareLocalFile, + shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, + shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile, } from '../../lib/email-export'; import { getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize, @@ -67,6 +68,21 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne const [preview, setPreview] = React.useState(null); const [previewLoading, setPreviewLoading] = React.useState(false); const previewSource = React.useRef(null); + // The file the open preview shows, in a temp folder of its own: images are + // cached by path, so two attachments both called "image001.png" must not + // share one. Deleted when the preview closes, unless it went to another + // app, which may still be reading it; the stale-export sweep gets that one. + const previewFile = React.useRef<{ file: File; handedOff: boolean } | null>(null); + // Bumped whenever a preview ends, so a download that finishes after its + // preview was closed is dropped instead of reopening it. + const previewSeq = React.useRef(0); + const endPreview = React.useCallback(() => { + previewSeq.current += 1; + const open = previewFile.current; + previewFile.current = null; + if (open && !open.handedOff) discardPreviewFile(open.file); + }, []); + React.useEffect(() => endPreview, [endPreview]); const items = React.useMemo(() => { const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked }) @@ -122,6 +138,9 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne const kind = previewKindFor({ name: item.name, type: item.type }); if (kind === 'none') { void open(item); return; } previewSource.current = item; + endPreview(); + const seq = previewSeq.current; + const stillOpen = () => seq === previewSeq.current; setPreviewLoading(true); setPreview(null); void (async () => { @@ -130,22 +149,27 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne const bytes = item.kind === 'blob' ? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId) : item.part.bytes; - if (kind === 'eml') { - 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) }); - } + const next: PreviewItem = kind === 'eml' + ? { kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) } + : { kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) }; + if (stillOpen()) setPreview(next); return; } const file = item.kind === 'blob' - ? await cacheBlobFile(item.att.blobId, item.name, item.type, jmapAccountId) - : writeTempFile(item.part.bytes, item.name, item.type); + ? await cachePreviewFile(item.att.blobId, item.name, item.type, jmapAccountId) + : writePreviewFile(item.part.bytes, item.name, item.type); + if (!stillOpen()) { + discardPreviewFile(file); + return; + } + previewFile.current = { file, handedOff: false }; setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri }); } catch (e) { + if (!stillOpen()) return; setPreview(null); Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e)); } finally { - setPreviewLoading(false); + if (stillOpen()) setPreviewLoading(false); } })(); }; @@ -257,7 +281,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne { setPreview(null); setPreviewLoading(false); }} + onClose={() => { + endPreview(); + setPreview(null); + setPreviewLoading(false); + }} onOpenExternal={() => { const it = previewSource.current; if (!it) return; @@ -270,9 +298,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne onShare={() => { const it = previewSource.current; if (!it) return; - if (preview?.fileUri) { - const { File } = require('expo-file-system') as typeof import('expo-file-system'); - void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined); + const open = previewFile.current; + if (open) { + // The receiving app may keep reading it after the sheet closes. + open.handedOff = true; + void shareLocalFile(open.file, it.type, it.name, { forceSheet: true, keep: true }).catch(() => undefined); } else { void share(it); } diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts index 47308cd4..fd720a24 100644 --- a/src/lib/__tests__/email-export.test.ts +++ b/src/lib/__tests__/email-export.test.ts @@ -109,7 +109,7 @@ import { File, Directory } from 'expo-file-system'; import { getDownloadUrl } from '../../api/blob'; import { shareAttachment, downloadAttachment, cachePreviewFile, discardPreviewFile, saveLocalFileCopy, - shareLocalFile, + shareLocalFile, writePreviewFile, } from '../email-export'; const VIEW = 'android.intent.action.VIEW'; @@ -255,6 +255,26 @@ describe('cachePreviewFile (in-app preview)', () => { }); }); +describe('writePreviewFile (parts unpacked on the device)', () => { + it('writes into a folder of its own, so two parts with one name get different paths', () => { + const bytes = new Uint8Array([1, 2, 3]); + const a = writePreviewFile(bytes, 'image001.png', 'image/png'); + const b = writePreviewFile(bytes, 'image001.png', 'image/png'); + + expect(a.name).toBe('image001.png'); + expect(a.uri).toMatch(/^file:\/\/\/cache\/bulwark-exports\/preview-[^/]+\/image001\.png$/); + expect(a.uri).not.toBe(b.uri); + }); + + it('is removed with its folder like a downloaded preview', () => { + fsState.deleted.length = 0; + const file = writePreviewFile(new Uint8Array([1]), 'notes.txt', 'text/plain'); + discardPreviewFile(file); + + expect(fsState.deleted).toEqual([file.uri.replace(/notes\.txt$/, '')]); + }); +}); + describe('discardPreviewFile', () => { beforeEach(() => { fsState.deleted.length = 0; diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 777defbb..ae06ff02 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -312,6 +312,11 @@ export async function saveLocalFileCopy(file: File, mimeType: string, filename = let previewSeq = 0; +function newPreviewDir(): Directory { + previewSeq += 1; + return new Directory(exportsDir(), `${PREVIEW_DIR_PREFIX}${Date.now().toString(36)}-${previewSeq.toString(36)}`); +} + /** * 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 @@ -328,8 +333,7 @@ export async function cachePreviewFile( accountId?: string, ): Promise { void sweepStaleExportFiles(); - previewSeq += 1; - const dir = new Directory(exportsDir(), `${PREVIEW_DIR_PREFIX}${Date.now().toString(36)}-${previewSeq.toString(36)}`); + const dir = newPreviewDir(); const filename = safeAttachmentName(name, type); const url = getDownloadUrl(blobId, filename, type || 'application/octet-stream', accountId); try { @@ -342,7 +346,21 @@ export async function cachePreviewFile( } } -/** Delete a file from {@link cachePreviewFile} together with its folder. */ +/** + * {@link cachePreviewFile} for bytes already in memory (parts unpacked on the + * device, e.g. from winmail.dat). + */ +export function writePreviewFile(bytes: Uint8Array, name: string | undefined, type: string | undefined): File { + void sweepStaleExportFiles(); + const dir = newPreviewDir(); + ensureDir(dir); + const file = new File(dir, safeAttachmentName(name, type)); + file.create(); + file.write(bytes); + return file; +} + +/** Delete a file from {@link cachePreviewFile} or {@link writePreviewFile} together with its folder. */ export function discardPreviewFile(file: File): void { try { const dir = file.parentDirectory;