mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
refactor: open files from mail, the list, Files and compose through one preview
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user