diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index 6d1609ea..3f3232a1 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -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(null); const [sheetItem, setSheetItem] = React.useState(null); - 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 preview = useFilePreview(); const items = React.useMemo(() => { 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: , - 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)} /> - { - 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); - } - }} - /> + ); } diff --git a/src/components/email/ListAttachmentChips.tsx b/src/components/email/ListAttachmentChips.tsx index 5d44f261..a457666f 100644 --- a/src/components/email/ListAttachmentChips.tsx +++ b/src/components/email/ListAttachmentChips.tsx @@ -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( function ListAttachmentOpener(_props, ref) { const t = useLocaleStore((s) => s.t); - const [preview, setPreview] = React.useState(null); - const [loading, setLoading] = React.useState(false); - const target = React.useRef(null); + const preview = useFilePreview(); const busy = React.useRef(false); const run = React.useCallback(async (fn: () => Promise) => { @@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef } }, [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 ( - { 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 ; }, ); diff --git a/src/components/email/use-file-preview.ts b/src/components/email/use-file-preview.ts new file mode 100644 index 00000000..2b2967dc --- /dev/null +++ b/src/components/email/use-file-preview.ts @@ -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): PreviewKind { + return inAppPreviewKind(source, PREVIEW_OPTIONS); +} + +/** Copy the source into a preview folder of its own (see cachePreviewFile). */ +async function materialize(source: PreviewSource): Promise { + 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 { + 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, + source: PreviewSource, + local: File, + tooLarge: string, +): Promise { + 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 `` 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(null); + const [loading, setLoading] = React.useState(false); + const [title, setTitle] = React.useState(''); + const source = React.useRef(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 => { + 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, 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 }; +} diff --git a/src/components/files/FilePreviewModal.tsx b/src/components/files/FilePreviewModal.tsx index bb438d43..d01dd3ff 100644 --- a/src/components/files/FilePreviewModal.tsx +++ b/src/components/files/FilePreviewModal.tsx @@ -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; /** 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(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 => { - 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'), - )} - /> - ); + return ; } diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 04fd9768..b189caf2 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -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). diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 8d1a87ae..a122c7f1 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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; @@ -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); }} /> + + ); if (!isSplit) return composer;