import * as Schema from "effect/Schema"; import * as Dialog from "./ui/Dialog"; import { useContext, useEffect, useEffectEvent, useState } from "react"; import { Platform } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { downloadAndShareAttachment, nativeViewerErrorMessage, shareLocalAttachment, } from "../lib/attachmentDownload"; import { usePreviewPresentation } from "../lib/previewPresentation"; import { refreshAssetUrl } from "../state/assets"; import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery"; import { FilePreviewGallery } from "./FilePreviewGallery"; import { FilePreview } from "./FilePreview"; import type { FilePreviewItem, FilePreviewSource } from "./FilePreviewModal.types"; export type { FilePreviewItem, FilePreviewSource, ResolvedFilePreviewSource, } from "./FilePreviewModal.types"; class PreviewResolutionError extends Schema.TaggedError()( "PreviewResolutionError", { cause: Schema.Defect(), fallbackMessage: Schema.String }, ) { override get message(): string { return this.fallbackMessage; } } const isPreviewResolutionError = Schema.is(PreviewResolutionError); interface ResolvedPreviewUri { readonly uri: string; readonly dispose?: () => void; } const PREVIEW_URL_RETRY_DELAYS_MS = [0, 200, 500] as const; function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new DOMException("The preview was cancelled.", "AbortError"); } function isAbortError(error: unknown): boolean { return ( (typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") || (error instanceof Error && error.name === "AbortError") ); } async function waitForRetry(ms: number, signal: AbortSignal): Promise { if (signal.aborted) throw abortError(signal); if (ms <= 0) return; await new Promise((resolve, reject) => { const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timer); reject(abortError(signal)); }; signal.addEventListener("abort", onAbort, { once: true }); }); } async function resolveRemotePreviewUri( item: Extract, signal: AbortSignal, retries: boolean, ): Promise { const delays = retries ? PREVIEW_URL_RETRY_DELAYS_MS : ([0] as const); let lastError: unknown; for (const [attempt, delay] of delays.entries()) { if (attempt > 0) await waitForRetry(delay, signal); try { // Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them. const url = await refreshAssetUrl(item.environmentId, item.resource, signal); if (url) return { uri: url + (item.srcFragment ?? "") }; lastError = new Error("Reconnect to this environment and try again."); } catch (error: unknown) { if (signal.aborted || isAbortError(error)) throw abortError(signal); lastError = error; } } throw new PreviewResolutionError({ cause: lastError, fallbackMessage: "Reconnect to this environment and try again. The file may have been moved or deleted.", }); } async function resolvePreviewUri( item: FilePreviewItem, signal: AbortSignal, retries = false, ): Promise { if ("uri" in item) return { uri: item.uri }; if ("attachment" in item) { return loadLocalAttachmentPreview(item.attachment, signal).catch((error: unknown) => { throw new PreviewResolutionError({ cause: error, fallbackMessage: "Attach the file again and retry.", }); }); } return resolveRemotePreviewUri(item, signal, retries); } function ResolvedFilePreview(props: { readonly source: FilePreviewSource; readonly onRequestClose: () => void; readonly onOpenError?: (error: unknown) => void; }) { const [attempt, setAttempt] = useState(0); const [resolutionFailed, setResolutionFailed] = useState(false); const onResolutionError = useEffectEvent((error: PreviewResolutionError) => { if (props.onOpenError) { props.onOpenError(error.cause); props.onRequestClose(); return; } setResolutionFailed(true); Dialog.alert("Could not open preview", error.fallbackMessage, [ { text: "Cancel", style: "cancel", onPress: props.onRequestClose }, { text: "Try again", onPress: () => { setResolutionFailed(false); setAttempt((count) => count + 1); }, }, ]); }); if (resolutionFailed) return null; return ( ); } function ResolvedFilePreviewAttempt(props: { readonly source: FilePreviewSource; readonly onRequestClose: () => void; readonly onOpenError?: (error: unknown) => void; readonly onResolutionError: (error: PreviewResolutionError) => void; }) { // Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer. const [items] = useState(() => filePreviewGallery(props.source, [])); const selectedKey = filePreviewKey(props.source); const selectedIndex = items.findIndex((item) => filePreviewKey(item) === selectedKey); const progressiveGallery = Platform.OS === "ios"; // Undefined while resolving, null once it cannot be. const [uris, setUris] = useState(() => items.map((item) => ("uri" in item ? item.uri : undefined)), ); const onResolutionError = useEffectEvent(props.onResolutionError); // Resolves once per presentation and holds local file leases until it closes. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves preview files and holds their local file leases until the preview closes useEffect(() => { const controller = new AbortController(); const leases: (() => void)[] = []; const settle = (index: number, uri: string | null) => setUris((current) => current.map((value, at) => (at === index ? uri : value))); items.forEach((item, index) => { if ("uri" in item) return; void resolvePreviewUri(item, controller.signal, index === selectedIndex).then( (resolved) => { if (controller.signal.aborted) return resolved?.dispose?.(); if (resolved?.dispose) leases.push(resolved.dispose); settle(index, resolved?.uri ?? null); }, (error: unknown) => { if (controller.signal.aborted || isAbortError(error)) return; if (progressiveGallery && index !== selectedIndex) return settle(index, null); controller.abort(); onResolutionError( isPreviewResolutionError(error) ? error : new PreviewResolutionError({ cause: error, fallbackMessage: "Try again." }), ); }, ); }); return () => { controller.abort(); for (const dispose of leases) dispose(); }; }, [items, selectedIndex, progressiveGallery]); const selected = { ...items[selectedIndex]!, name: filePreviewName(items[selectedIndex]!) }; if (selected.kind === "image") { // Android's viewer resets image gestures when its sources change during a presentation. if (!progressiveGallery && uris.some((uri) => uri === undefined)) return null; const images = items.map((item, index) => { const uri = uris[index]; return { ...item, name: filePreviewName(item), ...(uri ? { uri } : uri === null ? { failed: true } : {}), }; }); return ( ); } const uri = uris[selectedIndex]; if (!uri) return null; const onOpenError = (error: unknown) => { if (props.onOpenError) return props.onOpenError(error); Dialog.alert("Could not open preview", nativeViewerErrorMessage(error), [ { text: "Cancel", style: "cancel" }, ...(selected.kind === "document" ? [ { text: "Save or share", onPress: () => { const input = { attachment: { name: selected.name, mimeType: selected.mimeType ?? "application/octet-stream", }, signal: new AbortController().signal, }; const sharing = /^(file|content):/.test(uri) ? shareLocalAttachment({ ...input, uri }) : downloadAndShareAttachment({ ...input, url: uri }); void sharing.catch(() => Dialog.alert("Could not share file", "Please try again.")); }, }, ] : []), ]); }; return ( ); } export function FilePreviewModal(props: { readonly source: FilePreviewSource | null; readonly onRequestClose: () => void; readonly onOpenError?: (error: unknown) => void; }) { const group = useContext(FilePreviewGallery); const source = props.source === null ? null : { ...props.source, gallery: filePreviewGallery(props.source, group) }; usePreviewPresentation(source && filePreviewKey(source), props.onRequestClose); if (!source) return null; return ( ); }