mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 00:06:39 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user