fix: show the right image when two attachments share a name

The reader's attachment preview wrote every file to the same
`bulwark-exports/<name>`. React Native caches images by path, so a
second attachment with the same name (such as Outlook's `image001.png`)
could show the first one's picture. The preview now uses a temp folder
of its own per preview, as the Files tab does, also for parts unpacked
on the device, and deletes it when the preview closes unless the file
was shared to another app. A download that finishes after its preview
was closed no longer reopens it.
This commit is contained in:
Linus Rath
2026-09-24 19:45:07 +02:00
parent 1fb60a5c61
commit d8db4a5597
3 changed files with 86 additions and 18 deletions
+44 -14
View File
@@ -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<PreviewItem | null>(null);
const [previewLoading, setPreviewLoading] = React.useState(false);
const previewSource = React.useRef<Item | null>(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<Item[]>(() => {
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
<AttachmentPreviewModal
item={preview}
loading={previewLoading}
onClose={() => { 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);
}
+21 -1
View File
@@ -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;
+21 -3
View File
@@ -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<File> {
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;