refactor: open files from mail, the list, Files and compose through one preview

This commit is contained in:
Linus Rath
2026-10-16 18:48:26 +02:00
parent 14dc5ff2d4
commit 7c534301ee
6 changed files with 297 additions and 289 deletions
+29 -96
View File
@@ -1,23 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
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, fetchBlobBytes,
shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile,
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, shareBytes, attachmentSaveName,
} from '../../lib/email-export';
import {
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
getAttachmentDisplayName, visibleAttachments, formatSize,
} from '../../lib/attachment-display';
import { emailExportFilename } from '../../lib/download-filename';
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import type { ExtractedAttachment } from './use-body-override';
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
interface Props {
email: Email;
@@ -65,24 +64,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const [expanded, setExpanded] = React.useState(false);
const [busyKey, setBusyKey] = React.useState<string | null>(null);
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
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 preview = useFilePreview();
const items = React.useMemo<Item[]>(() => {
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
@@ -134,49 +116,29 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
const showPreview = (item: Item) => {
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 () => {
try {
if (kind === 'text' || kind === 'eml') {
const bytes = item.kind === 'blob'
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
: item.part.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 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 {
if (stillOpen()) setPreviewLoading(false);
}
})();
};
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
? {
from: 'blob',
blobId: item.att.blobId,
accountId: jmapAccountId,
email,
name: item.name,
type: item.type,
size: item.size,
saveName: attachmentSaveName(email, item.att.name, item.att.type),
}
: { from: 'bytes', bytes: item.part.bytes, name: item.name, type: item.type, size: item.size });
// The in-app preview where the type has one, else straight to another app.
const showPreview = (item: Item) => run(
item.key,
() => preview.open(sourceOf(item)),
t('email_viewer.attachment_failed', 'Could not open attachment'),
);
const onTap = (item: Item) => {
if (mailAttachmentAction === 'download') void save(item);
else showPreview(item);
else void showPreview(item);
};
const downloadAll = () => run('__all__', async () => {
@@ -204,11 +166,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const visible = expanded ? items : items.slice(0, 3);
const sheetActions: ActionSheetItem[] = sheetItem ? [
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
...(previewKindOf(sheetItem) !== 'none' ? [{
key: 'preview',
label: t('email_viewer.attachment_actions.preview', 'Preview'),
icon: <Eye size={18} color={c.textSecondary} />,
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
onPress: () => { const it = sheetItem; setSheetItem(null); void showPreview(it); },
}] : []),
{
key: 'open',
@@ -278,36 +240,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
onClose={() => setSheetItem(null)}
/>
<AttachmentPreviewModal
item={preview}
loading={previewLoading}
onClose={() => {
endPreview();
setPreview(null);
setPreviewLoading(false);
}}
onOpenExternal={() => {
const it = previewSource.current;
if (!it) return;
if (preview?.fileUri && it.kind === 'blob') {
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
} else {
void open(it);
}
}}
onShare={() => {
const it = previewSource.current;
if (!it) return;
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);
}
}}
/>
<AttachmentPreviewModal {...preview.modalProps} />
</View>
);
}
+21 -62
View File
@@ -15,9 +15,10 @@ import {
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
type AttachmentKind, type LoadListAttachments,
} from '../../lib/list-attachments';
import { getAttachmentDisplayName, previewKindFor } from '../../lib/attachment-display';
import { cacheBlobFile, downloadAttachment, fetchBlobBytes, shareAttachment, shareLocalFile } from '../../lib/email-export';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { getAttachmentDisplayName } from '../../lib/attachment-display';
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import { useFilePreview } from './use-file-preview';
/** How many chips a row shows before collapsing the rest into a count. */
const MAX_CHIPS = 2;
@@ -124,26 +125,16 @@ export interface ListAttachmentOpenerHandle {
open: (email: Email, attachment: Attachment, accountId?: string) => void;
}
interface OpenTarget {
email: Email;
attachment: Attachment;
accountId?: string;
name: string;
type: string;
}
/**
* Opens a list-row attachment the way the reader's chips do: the "attachment
* click action" setting either saves it or previews it in-app (images, PDFs,
* text), and anything that can't be previewed goes to the platform viewer.
* Holds the preview modal, so the list itself never re-renders for it.
* click action" setting either saves it or previews it in-app, and anything
* that can't be previewed goes to another app. Holds the preview modal, so
* the list itself never re-renders for it.
*/
export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>(
function ListAttachmentOpener(_props, ref) {
const t = useLocaleStore((s) => s.t);
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
const target = React.useRef<OpenTarget | null>(null);
const preview = useFilePreview();
const busy = React.useRef(false);
const run = React.useCallback(async (fn: () => Promise<void>) => {
@@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>
}
}, [t]);
const openExternal = React.useCallback((it: OpenTarget) => run(
() => shareAttachment(it.attachment.blobId, it.attachment.name, it.attachment.type, it.email, it.accountId),
), [run]);
const { open: openPreview } = preview;
React.useImperativeHandle(ref, () => ({
open: (email, attachment, accountId) => {
if (busy.current) return;
const name = getAttachmentDisplayName(attachment.name, attachment.type);
const type = attachment.type || 'application/octet-stream';
const it: OpenTarget = { email, attachment, accountId, name, type };
target.current = it;
if (useSettingsStore.getState().mailAttachmentAction === 'download') {
void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId));
return;
}
const kind = previewKindFor({ name, type });
if (kind !== 'image' && kind !== 'pdf' && kind !== 'text') {
void openExternal(it);
return;
}
setPreview(null);
setLoading(true);
void run(async () => {
try {
if (kind === 'text') {
const bytes = await fetchBlobBytes(attachment.blobId, attachment.name, attachment.type, accountId);
setPreview({ kind, name, mimeType: type, text: new TextDecoder('utf-8').decode(bytes) });
} else {
const file = await cacheBlobFile(attachment.blobId, name, type, accountId);
setPreview({ kind, name, mimeType: type, fileUri: file.uri });
}
} finally {
setLoading(false);
}
});
void run(() => openPreview({
from: 'blob',
blobId: attachment.blobId,
accountId,
email,
name: getAttachmentDisplayName(attachment.name, attachment.type),
type: attachment.type || 'application/octet-stream',
size: attachment.size,
saveName: attachmentSaveName(email, attachment.name, attachment.type),
}));
},
}), [run, openExternal]);
}), [run, openPreview]);
return (
<AttachmentPreviewModal
item={preview}
loading={loading}
onClose={() => { setPreview(null); setLoading(false); }}
onOpenExternal={() => { if (target.current) void openExternal(target.current); }}
onShare={() => {
const it = target.current;
if (!it || !preview?.fileUri) return;
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);
}}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} />;
},
);
+205
View File
@@ -0,0 +1,205 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import type { File } from 'expo-file-system';
import type { Email } from '../../api/types';
import {
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
shareBytes, shareLocalFile, writePreviewFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import type { PreviewKind } from '../../lib/attachment-display';
import { useLocaleStore } from '../../stores/locale-store';
import type { PreviewItem } from './AttachmentPreviewModal';
import { emlPreviewFromBytes } from './use-body-override';
export const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
interface PreviewSourceBase {
/** Display name, shown in the header and given to the cached copy. */
name: string;
type: string;
size?: number | null;
/** Name for "Download" (the attachment filename template); defaults to `name`. */
saveName?: string;
}
/** Where a previewed file comes from. */
export type PreviewSource = PreviewSourceBase & (
| { from: 'blob'; blobId: string; accountId?: string; email?: Email | null }
| { from: 'bytes'; bytes: Uint8Array }
| { from: 'local'; uri: string }
);
/** The preview a tap on `source` opens, or 'none' when it goes to another app. */
export function previewKindOf(source: Pick<PreviewSource, 'name' | 'type' | 'size'>): PreviewKind {
return inAppPreviewKind(source, PREVIEW_OPTIONS);
}
/** Copy the source into a preview folder of its own (see cachePreviewFile). */
async function materialize(source: PreviewSource): Promise<File> {
switch (source.from) {
case 'blob':
return cachePreviewFile(source.blobId, source.name, source.type, source.accountId);
case 'bytes':
return writePreviewFile(source.bytes, source.name, source.type);
case 'local':
return copyPreviewFile(source.uri, source.name, source.type);
}
}
/** Hand a file the app doesn't show to the app that owns its type ("Open with"). */
function handOff(source: PreviewSource): Promise<void> {
switch (source.from) {
case 'blob':
return shareAttachment(source.blobId, source.name, source.type, source.email, source.accountId);
case 'bytes':
return shareBytes(source.bytes, source.name, source.type);
case 'local':
// A copy, so the cleanup after the hand-off never removes the original.
return shareLocalFile(copyPreviewFile(source.uri, source.name, source.type), source.type, source.name);
}
}
async function buildItem(
kind: Exclude<PreviewKind, 'none'>,
source: PreviewSource,
local: File,
tooLarge: string,
): Promise<PreviewItem> {
const base = { name: source.name, mimeType: source.type };
// The source may not know its size; the copy does.
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: tooLarge };
switch (kind) {
case 'text':
return { ...base, kind, text: await local.text() };
case 'eml':
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
default:
return { ...base, kind, fileUri: local.uri };
}
}
/**
* One in-app preview: picks how a file is shown, downloads it once into a
* temp folder of its own, and serves Open with, Share and Download from that
* copy. Files the app can't show go straight to another app. The copy is
* deleted when the preview closes or the next one opens, unless another app
* got it, which may still be reading it; the stale-export sweep removes
* those. Used by every place that opens a file: the reader's chips, the
* list's chips, the Files tab and compose.
*
* Render `<AttachmentPreviewModal {...preview.modalProps} />` next to it.
*/
export function useFilePreview(opts: {
/** Called when the preview closes: the close button, or a download that failed. */
onClose?: () => void;
} = {}) {
const t = useLocaleStore((s) => s.t);
const onCloseRef = React.useRef(opts.onClose);
onCloseRef.current = opts.onClose;
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
const [title, setTitle] = React.useState('');
const source = React.useRef<PreviewSource | null>(null);
const session = 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 seq = React.useRef(0);
const busy = React.useRef(false);
const end = React.useCallback(() => {
seq.current += 1;
const open = session.current;
session.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
}, []);
React.useEffect(() => end, [end]);
/** Close without telling the owner (it closed the preview itself). */
const reset = React.useCallback(() => {
end();
source.current = null;
setItem(null);
setLoading(false);
}, [end]);
const close = React.useCallback(() => {
reset();
onCloseRef.current?.();
}, [reset]);
/**
* Show `src` in the preview, or hand it to another app when the preview
* can't show it. The promise settles once that hand-off is done (and
* rejects when it fails); a preview resolves at once.
*/
const open = React.useCallback((src: PreviewSource): Promise<void> => {
const kind = previewKindOf(src);
if (kind === 'none') return handOff(src);
end();
source.current = src;
const mine = seq.current;
const current = () => mine === seq.current;
setTitle(src.name);
setItem(null);
setLoading(true);
void (async () => {
try {
const local = await materialize(src);
if (!current()) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await buildItem(kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'));
if (current()) setItem(next);
} catch (e) {
if (!current()) return;
close();
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
} finally {
if (current()) setLoading(false);
}
})();
return Promise.resolve();
}, [end, close, t]);
// The preview's own buttons, all on the downloaded copy.
const act = React.useCallback((run: (local: File, src: PreviewSource) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
const src = source.current;
if (!open || !src || 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, src)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
}, []);
const modalProps = React.useMemo(() => ({
item,
loading,
title,
onClose: close,
onOpenExternal: () => act(
(local, src) => shareLocalFile(local, src.type, src.name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
),
onShare: () => act(
(local, src) => shareLocalFile(local, src.type, src.name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
),
onDownload: () => act(
(local, src) => saveLocalFileCopy(local, src.type, src.saveName ?? src.name),
false,
t('files.download_error', 'Failed to download'),
),
}), [item, loading, title, close, act, t]);
return { open, close, reset, modalProps };
}
+12 -116
View File
@@ -1,24 +1,13 @@
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' };
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
/** 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';
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
}
interface Props {
@@ -30,22 +19,13 @@ interface Props {
}
/**
* 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.
* The Files tab's in-app preview: the shared file preview fed from a
* FileNode, downloaded with the same authenticated download as "Download"
* (token refresh, client certificate).
*/
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(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 preview = useFilePreview({ onClose });
const { open, reset } = preview;
const blobId = file?.blobId ?? null;
const accountId = file?.accountId;
@@ -55,93 +35,9 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
React.useEffect(() => {
if (!blobId) return undefined;
let cancelled = false;
setItem(null);
setLoading(true);
void open({ from: 'blob', blobId, accountId, name, type, size });
return reset;
}, [blobId, accountId, name, type, size, open, reset]);
const build = async (local: File): Promise<PreviewItem> => {
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<void>, 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 (
<AttachmentPreviewModal
item={item}
loading={loading}
title={name}
docked={docked}
onClose={onClose}
onOpenExternal={() => 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'),
)}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
}
+19
View File
@@ -47,6 +47,11 @@ function attachmentFileOptions(): EmailFilenameOptions {
};
}
/** The name "Save" gives an attachment of `email`: the user's attachment filename template. */
export function attachmentSaveName(email: Email, name: string | undefined, type: string | undefined): string {
return attachmentDownloadFilename(email, { name, type }, attachmentFileOptions());
}
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
@@ -347,6 +352,20 @@ export async function cachePreviewFile(
}
}
/**
* {@link cachePreviewFile} for a file already on the device (a compose
* attachment): copied, so the preview owns its copy and the original is never
* deleted with it.
*/
export function copyPreviewFile(uri: string, name: string | undefined, type: string | undefined): File {
void sweepStaleExportFiles();
const dir = newPreviewDir();
ensureDir(dir);
const file = new File(dir, safeAttachmentName(name, type));
new File(uri).copy(file);
return file;
}
/**
* {@link cachePreviewFile} for bytes already in memory (parts unpacked on the
* device, e.g. from winmail.dat).
+11 -15
View File
@@ -17,7 +17,6 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import * as Clipboard from 'expo-clipboard';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -83,6 +82,8 @@ import type { EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import ToggleSwitch from '../components/ToggleSwitch';
import { AttachmentPreviewModal } from '../components/email/AttachmentPreviewModal';
import { useFilePreview } from '../components/email/use-file-preview';
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
@@ -398,6 +399,7 @@ function OptionsSheet({
}
export default function ComposeScreen({ route, navigation }: Props) {
const filePreview = useFilePreview();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -1745,22 +1747,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
await insertInlineImages(result.assets);
};
// Open a chip: local files straight from their URI, server blobs after a
// download into the cache.
// Open a chip in the file preview: local files from their URI, server
// blobs (forwarded, from a draft) after a download.
const previewAttachment = async (entry: AttachmentEntry) => {
try {
let uri = entry.uri;
if (!uri && entry.blobId) {
const buf = await jmapClient.fetchBlobArrayBuffer(entry.blobId, entry.name, entry.type);
const dir = FileSystem.cacheDirectory ?? '';
uri = `${dir}${entry.localId}-${entry.name.replace(/[^\w.-]+/g, '_')}`;
await FileSystem.writeAsStringAsync(uri, bytesToBase64(new Uint8Array(buf)), {
encoding: FileSystem.EncodingType.Base64,
});
}
if (!uri) return;
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: entry.type, dialogTitle: entry.name });
if (entry.uri) {
await filePreview.open({ from: 'local', uri: entry.uri, name: entry.name, type: entry.type, size: entry.size });
} else if (entry.blobId) {
await filePreview.open({ from: 'blob', blobId: entry.blobId, name: entry.name, type: entry.type, size: entry.size });
}
} catch (err) {
Alert.alert(t('email_composer.attach', 'Attach'), err instanceof Error ? err.message : String(err));
@@ -2959,6 +2953,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
selectedIdentityId={selectedIdentityId}
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); }}
/>
<AttachmentPreviewModal {...filePreview.modalProps} />
</SafeAreaView>
);
if (!isSplit) return composer;