Skip to content
Source
Sign inSign up

FilePreviewSession.tsx

235 lines8.9 KB

1import { AssetWorkspaceAssetNotFoundError } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import * as Dialog from "./Dialog";
4import { useContext, useEffect, useEffectEvent, useState, type ComponentType } from "react";
5​
6import { loadLocalAttachmentPreview } from "../../lib/localAttachmentPreview";
7import {
8 downloadAndShareAttachment,
9 nativeViewerErrorMessage,
10 shareLocalAttachment,
11} from "../../lib/attachmentDownload";
12import { usePreviewPresentation } from "../../lib/previewPresentation";
13import { refreshAssetUrl } from "../../state/assets";
14import { filePreviewGallery, filePreviewKey, filePreviewName } from "../../lib/filePreviewGallery";
15import { FilePreviewGallery } from "./FilePreviewGallery";
16import type { MediaActionsSource } from "../../lib/mediaActionsSource";
17import type {
18 FilePreviewItem,
19 FilePreviewModalProps,
20 FilePreviewSource,
21 FilePreviewViewerProps,
22} from "./FilePreviewModal.types";
23​
24interface FilePreviewSessionOptions {
25 /** Opens before every image resolves; the viewer takes later URLs as they arrive. */
26 readonly progressiveGallery: boolean;
27 readonly Viewer: ComponentType<FilePreviewViewerProps>;
29​
30class PreviewResolutionError extends Schema.TaggedError<PreviewResolutionError>()(
31 "PreviewResolutionError",
32 { cause: Schema.Defect(), fallbackMessage: Schema.String },
33) {
34 override get message(): string {
35 return this.fallbackMessage;
36 }
38​
39const isPreviewResolutionError = Schema.is(PreviewResolutionError);
40const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError);
41​
42function remotePreviewFailureMessage(error: unknown): string {
43 if (!isAssetNotFoundError(error)) {
44 return "Reconnect to this environment and try again. The file may have been moved or deleted.";
45 }
46 const path = error.checkedPath ?? ("path" in error.resource ? error.resource.path : undefined);
47 return path ? `The file was not found at ${path}.` : error.message;
49​
50interface ResolvedPreviewUri {
51 readonly uri: string;
52 readonly dispose?: () => void;
54​
55async function resolvePreviewUri(
56 item: FilePreviewItem,
57 signal: AbortSignal,
58): Promise<ResolvedPreviewUri | null> {
59 if ("uri" in item) return { uri: item.uri };
60 if ("attachment" in item) {
61 return loadLocalAttachmentPreview(item.attachment, signal).catch((error: unknown) => {
62 throw new PreviewResolutionError({
63 cause: error,
64 fallbackMessage: "Attach the file again and retry.",
65 });
66 });
67 }
68 // Refresh expired URLs before handing them to Quick Look or ACTION_VIEW, which retain them.
69 const url = await refreshAssetUrl(item.environmentId, item.resource, signal)
70 .then((value) => {
71 if (!value) throw new Error("Reconnect to this environment and try again.");
72 return value;
73 })
74 .catch((error: unknown) => {
75 throw new PreviewResolutionError({
76 cause: error,
77 fallbackMessage: remotePreviewFailureMessage(error),
78 });
79 });
80 return { uri: url + (item.srcFragment ?? "") };
82​
83// Shares from the unresolved item so remote URLs refresh and draft files take their own lease.
84function pdfActionsSource(item: FilePreviewItem): MediaActionsSource {
85 const metadata = {
86 name: filePreviewName(item),
87 mimeType: item.mimeType ?? "application/pdf",
88 ...(item.sourceIdentifier ? { sourceIdentifier: item.sourceIdentifier } : {}),
89 };
90 if ("uri" in item) return { ...metadata, uri: item.uri };
91 if ("attachment" in item) return { ...metadata, attachment: item.attachment };
92 return { ...metadata, environmentId: item.environmentId, resource: item.resource };
94​
95function ResolvedFilePreview(
96 props: FilePreviewSessionOptions & {
97 readonly source: FilePreviewSource;
98 readonly onRequestClose: () => void;
99 readonly onOpenError?: (error: unknown) => void;
100 },
101) {
102 const { progressiveGallery, Viewer } = props;
103 // Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer.
104 const [items] = useState(() => filePreviewGallery(props.source, []));
105 const selectedKey = filePreviewKey(props.source);
106 const selectedIndex = items.findIndex((item) => filePreviewKey(item) === selectedKey);
107 // Undefined while resolving, null once it cannot be.
108 const [uris, setUris] = useState<readonly (string | null | undefined)[]>(() =>
109 items.map((item) => ("uri" in item ? item.uri : undefined)),
110 );
111 const onResolutionError = useEffectEvent((error: PreviewResolutionError) => {
112 if (props.onOpenError) props.onOpenError(error.cause);
113 else Dialog.alert("Could not open preview", error.fallbackMessage);
114 props.onRequestClose();
115 });
116 // Resolves once per presentation and holds local file leases until it closes.
117 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resolves preview files and holds their local file leases until the preview closes
118 useEffect(() => {
119 const controller = new AbortController();
120 const leases: (() => void)[] = [];
121 const settle = (index: number, uri: string | null) =>
122 setUris((current) => current.map((value, at) => (at === index ? uri : value)));
123 items.forEach((item, index) => {
124 if ("uri" in item) return;
125 void resolvePreviewUri(item, controller.signal).then(
126 (resolved) => {
127 if (controller.signal.aborted) return resolved?.dispose?.();
128 if (resolved?.dispose) leases.push(resolved.dispose);
129 settle(index, resolved?.uri ?? null);
130 },
131 (error: unknown) => {
132 if (controller.signal.aborted) return;
133 if (progressiveGallery && index !== selectedIndex) return settle(index, null);
134 controller.abort();
135 onResolutionError(
136 isPreviewResolutionError(error)
137 ? error
138 : new PreviewResolutionError({ cause: error, fallbackMessage: "Try again." }),
139 );
140 },
141 );
142 });
143 return () => {
144 controller.abort();
145 for (const dispose of leases) dispose();
146 };
147 }, [items, selectedIndex, progressiveGallery]);
148 const selected = { ...items[selectedIndex]!, name: filePreviewName(items[selectedIndex]!) };
149 if (selected.kind === "image") {
150 // A viewer that resets image gestures when its sources change waits for every image.
151 if (!progressiveGallery && uris.some((uri) => uri === undefined)) return null;
152 const images = items.map((item, index) => {
153 const uri = uris[index];
154 return {
155 ...item,
156 name: filePreviewName(item),
157 ...(uri ? { uri } : uri === null ? { failed: true } : {}),
158 };
159 });
160 return (
161 <Viewer
162 source={{ kind: "image", images, selectedIndex }}
163 onRequestClose={props.onRequestClose}
164 {...(props.onOpenError ? { onOpenError: props.onOpenError } : {})}
165 />
166 );
167 }
168 const uri = uris[selectedIndex];
169 if (!uri) return null;
170 const onOpenError = (error: unknown) => {
171 if (props.onOpenError) return props.onOpenError(error);
172 Dialog.alert("Could not open preview", nativeViewerErrorMessage(error), [
173 { text: "Cancel", style: "cancel" },
174 ...(selected.kind === "document"
175 ? [
176 {
177 text: "Save or share",
178 onPress: () => {
179 const input = {
180 attachment: {
181 name: selected.name,
182 mimeType: selected.mimeType ?? "application/octet-stream",
183 },
184 signal: new AbortController().signal,
185 };
186 const sharing = /^(file|content):/.test(uri)
187 ? shareLocalAttachment({ ...input, uri })
188 : downloadAndShareAttachment({ ...input, url: uri });
189 void sharing.catch(() => Dialog.alert("Could not share file", "Please try again."));
190 },
191 },
192 ]
193 : []),
194 ]);
195 };
196 const actionsSource =
197 selected.kind === "pdf" ? (selected.actionsSource ?? pdfActionsSource(selected)) : undefined;
198 return (
199 <Viewer
200 source={{
201 ...selected,
202 kind: selected.kind,
203 uri,
204 ...(actionsSource ? { actionsSource } : {}),
205 }}
206 onRequestClose={props.onRequestClose}
207 onOpenError={onOpenError}
208 />
209 );
211​
212/**
213 * The part of FilePreviewModal both platforms share: it resolves sources, freezes the gallery
214 * at opening and closes with its screen. Each platform's FilePreviewModal passes its viewer.
215 */
216export function FilePreviewSession(props: FilePreviewModalProps & FilePreviewSessionOptions) {
217 const group = useContext(FilePreviewGallery);
218 const source =
219 props.source === null
220 ? null
221 : { ...props.source, gallery: filePreviewGallery(props.source, group) };
222 usePreviewPresentation(source && filePreviewKey(source), props.onRequestClose);
223​
224 if (!source) return null;
225 return (
226 <ResolvedFilePreview
227 key={filePreviewKey(source)}
228 source={source}
229 onRequestClose={props.onRequestClose}
230 progressiveGallery={props.progressiveGallery}
231 Viewer={props.Viewer}
232 {...(props.onOpenError ? { onOpenError: props.onOpenError } : {})}
233 />
234 );