Skip to content
Source
Sign inSign up
Open History to see the last commit

AttachmentFilePreview.tsx

491 lines18.0 KB

1import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
2import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
4import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview";
5import { settlePromise } from "@t3tools/client-runtime/state/runtime";
6import * as AsyncResult from "effect/reactivity/AsyncResult";
7import * as Cause from "effect/Cause";
8import {
9 filePreviewKind,
10 officePreviewKind,
11 FILE_TEXT_PREVIEW_MAX_BYTES,
12} from "@t3tools/shared/filePreview";
13import {
14 ChevronRightIcon,
15 DownloadIcon,
16 Trash2Icon,
17 WrapTextIcon,
18 XIcon,
19 CheckIcon,
20 Code2Icon,
21 CopyIcon,
22 EyeIcon,
23 Table2Icon,
24} from "lucide-react";
25import { lazy, Suspense, useEffect, useRef, useState } from "react";
26​
27import { useAssetUrlRefresh } from "~/assets/assetUrls";
28import ChatMarkdown from "~/components/ChatMarkdown";
29import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter";
30import { ScrollArea } from "~/components/ui/scroll-area";
31import { toastManager } from "~/components/ui/toast";
32import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
33import { useClientSettings } from "~/hooks/useSettings";
34import { cn } from "~/lib/utils";
35​
36import { BinaryFilePreview } from "./BinaryFilePreview";
37import { PreviewImage } from "./PreviewImage";
38import { AudioPreview } from "./AudioPreview";
39import { BrowserDocumentFrame } from "./BrowserDocumentFrame";
40import { DelimitedTablePreview } from "./DelimitedTablePreview";
41import {
42 FILE_SURFACE_SUBHEADER_CLASS,
43 FileSurfaceAction,
44 FileSurfaceFailure,
45 FileSurfaceLoading,
46 FileSurfaceNotice,
47} from "./fileSurfaceChrome";
48​
49const LargeTextViewer = lazy(() => import("./LargeTextViewer"));
50​
51const OfficeFilePreview = lazy(() =>
52 import("./OfficeFilePreview").then((module) => ({ default: module.OfficeFilePreview })),
53);
54​
55const FontFilePreview = lazy(() =>
56 import("./FontFilePreview").then((module) => ({ default: module.FontFilePreview })),
57);
58​
59const MermaidFilePreview = lazy(() =>
60 import("./MermaidFilePreview").then((module) => ({ default: module.MermaidFilePreview })),
61);
62​
63const SourcePreview = lazy(() => import("./ReadOnlySourcePreview"));
64​
65const STALE_URL_MS = 5 * 60_000;
66​
67const blobKeys = new WeakMap<Blob, string>();
68let nextBlobKey = 0;
69​
70function blobKey(blob: Blob) {
71 const key = blobKeys.get(blob) ?? `blob:${nextBlobKey++}`;
72 blobKeys.set(blob, key);
73 return key;
75​
76export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) {
77 return (
78 <Suspense fallback={<FileSurfaceLoading className="p-4" />}>
79 <SourcePreview {...props} />
80 </Suspense>
81 );
83​
84function renderedToggleLabel(
85 mode: "markdown" | "mermaid" | "html" | "table",
86 rendered: boolean,
87): string {
88 if (mode === "mermaid") return rendered ? "Show Mermaid source" : "Show rendered diagram";
89 if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown";
90 if (mode === "table") return rendered ? "Show source" : "Show table";
91 return rendered ? "Show HTML source" : "Show rendered page";
93​
94export function AttachmentFilePreview(props: {
95 name: string;
96 mimeType: string;
97 sizeBytes: number;
98 file?: Blob | null;
99 asset?:
100 | { environmentId: EnvironmentId; attachmentId: string }
101 | {
102 environmentId: EnvironmentId;
103 resource: Extract<AssetResource, { _tag: "media-file" | "draft-workspace-file" }>;
104 };
105 htmlRender?: boolean;
106 threadRef?: ScopedThreadRef;
107 origin?: string;
108 onRemove?: () => void;
109 onClose?: () => void;
110}) {
111 const kind = filePreviewKind(props);
112 const officeKind = officePreviewKind(props);
113 const delimiter = filePreviewDelimiter(props);
114 const renderedMode =
115 kind === "mermaid"
116 ? "mermaid"
117 : kind === "markdown"
118 ? "markdown"
119 : kind === "html"
120 ? "html"
121 : delimiter
122 ? "table"
123 : null;
124 const attachmentId =
125 props.asset && "attachmentId" in props.asset ? props.asset.attachmentId : undefined;
126 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: props.name });
127 // useAssetUrlRefresh keys its callback on these objects, so they must stay cached.
128 const resource =
129 props.asset && "resource" in props.asset
130 ? props.asset.resource
131 : attachmentId
132 ? {
133 _tag: "attachment" as const,
134 attachmentId,
135 fileName: props.name,
136 mimeType: props.mimeType,
137 disposition: "inline" as const,
138 }
139 : null;
140 const downloadResource = resource
141 ? resource._tag === "draft-workspace-file"
142 ? resource
143 : { ...resource, disposition: "attachment" as const }
144 : null;
145 const refresh = useAssetUrlRefresh(
146 props.file ? null : (props.asset?.environmentId ?? null),
147 props.file ? null : resource,
148 );
149 const prepareDownload = useAssetUrlRefresh(props.asset?.environmentId ?? null, downloadResource);
150 const [saving, setSaving] = useState(false);
151 const [localUrl, setLocalUrl] = useState<string | null>(null);
152 const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
153 const [rendered, setRendered] = useState(true);
154 const [revision, setRevision] = useState(0);
155 const [content, setContent] = useState<{ text: string; truncated: boolean } | null>(null);
156 const [error, setError] = useState<string | null>(null);
157 const [contentError, setContentError] = useState<string | null>(null);
158 const copyContents = useRef<(() => Promise<string>) | null>(null);
159 const authorizedAt = useRef(0);
160 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates an object URL for the local file and revokes it on cleanup
161 useEffect(() => {
162 if (!props.file) return;
163 const url = URL.createObjectURL(props.file);
164 // oxlint-disable-next-line react/set-state-in-effect -- Publish an object URL only after its cleanup is registered for this Blob.
165 setLocalUrl(url);
166 return () => URL.revokeObjectURL(url);
167 }, [props.file]);
168 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- authorizes a remote attachment URL from the server and ignores the result after cleanup
169 useEffect(() => {
170 if (props.file) return;
171 let cancelled = false;
172 // oxlint-disable-next-line react/set-state-in-effect -- A new preview request clears its previous URL and error.
173 setRemoteUrl(null);
174 setError(null);
175 void refresh()
176 .then((url) => {
177 if (cancelled) return;
178 if (!url) throw new Error("Reconnect to the environment and try again.");
179 authorizedAt.current = Date.now();
180 setRemoteUrl(url);
181 })
182 .catch((cause: unknown) => {
183 if (!cancelled)
184 setError(cause instanceof Error ? cause.message : "The attachment is unavailable.");
185 });
186 return () => {
187 cancelled = true;
188 };
189 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reauthorize the remote file.
190 }, [props.file, refresh, revision]);
191 const url = props.file ? localUrl : remoteUrl;
192 const streamsText =
193 kind === "text" &&
194 (props.sizeBytes > FILE_TEXT_PREVIEW_MAX_BYTES ||
195 (props.sizeBytes === 0 && (!resource || resource._tag === "attachment")));
196 const needsText =
197 kind === "text" || kind === "markdown" || kind === "mermaid" || (kind === "html" && !rendered);
198 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches the file's text from its URL or Blob stream and aborts the request on cleanup
199 useEffect(() => {
200 if (!needsText || streamsText || !url) return;
201 const controller = new AbortController();
202 // oxlint-disable-next-line react/set-state-in-effect -- A new external resource must clear the previous response before loading.
203 setContent(null);
204 setContentError(null);
205 const file = props.file;
206 void (async () => {
207 if (!file && Date.now() - authorizedAt.current > STALE_URL_MS) {
208 const target = await refresh();
209 if (controller.signal.aborted) return;
210 if (!target) throw new Error("Reconnect to the environment and try again.");
211 authorizedAt.current = Date.now();
212 if (target !== url) {
213 setRemoteUrl(target);
214 return;
215 }
216 }
217 const response = file
218 ? { ok: true, body: file.stream() }
219 : await fetch(url, {
220 signal: controller.signal,
221 ...(props.sizeBytes > 0
222 ? { headers: { Range: `bytes=0-${FILE_TEXT_PREVIEW_MAX_BYTES}` } }
223 : {}),
224 cache: revision === 0 ? "default" : "reload",
225 });
226 const result = await readFilePreviewResponse(response, controller.signal);
227 if (!controller.signal.aborted) setContent(result);
228 })().catch((cause: unknown) => {
229 if (!controller.signal.aborted)
230 setContentError(cause instanceof Error ? cause.message : "Could not load this file.");
231 });
232 return () => controller.abort();
233 }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]);
234 const failure = error ?? (needsText ? contentError : null);
235 const defaultWordWrap = useClientSettings((settings) => settings.wordWrap);
236 const [wordWrapOverride, setWordWrapOverride] = useState<boolean | null>(null);
237 const wordWrap = wordWrapOverride ?? defaultWordWrap;
238 const showsRawText =
239 failure === null &&
240 needsText &&
241 (content !== null || streamsText) &&
242 !(delimiter && rendered) &&
243 !((kind === "markdown" || kind === "mermaid") && rendered);
244​
245 const save = () => {
246 setSaving(true);
247 void settlePromise(async () => {
248 let file = props.file;
249 if (!file) {
250 const target = await prepareDownload();
251 if (!target) throw new Error("Reconnect to the environment and try again.");
252 const response = await fetch(target);
253 if (!response.ok) throw new Error("The file could not be loaded. Try again.");
254 file = await response.blob();
255 }
256 // Blob downloads keep desktop's custom app scheme from navigating to an external browser.
257 const downloadUrl = URL.createObjectURL(file);
258 // Scheduled before the click so the URL is revoked even if the click throws.
259 setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000);
260 const anchor = document.createElement("a");
261 anchor.href = downloadUrl;
262 anchor.download = props.name;
263 anchor.click();
264 }).then((result) => {
265 if (!AsyncResult.isSuccess(result)) {
266 const cause = Cause.squash(result.cause);
267 toastManager.add({
268 type: "error",
269 title: "Could not save file",
270 description: cause instanceof Error ? cause.message : "Please try again.",
271 });
272 }
273 setSaving(false);
274 });
275 };
276​
277 const body = failure ? (
278 <FileSurfaceFailure
279 message={failure}
280 onRetry={() => {
281 setError(null);
282 setRevision((value) => value + 1);
283 }}
284 />
285 ) : streamsText && url ? (
286 <Suspense fallback={<FileSurfaceLoading />}>
287 <LargeTextViewer
288 key={`${url}:${rendered}:${revision}`}
289 name={props.name}
290 src={url}
291 sizeBytes={props.sizeBytes}
292 onRetry={() => setRevision((value) => value + 1)}
293 delimiter={rendered ? delimiter : null}
294 copyContents={copyContents}
295 wordWrap={wordWrap}
296 />
297 </Suspense>
298 ) : !url || (needsText && !content) ? (
299 <FileSurfaceLoading />
300 ) : needsText && content ? (
301 delimiter && rendered ? (
302 <DelimitedTablePreview name={props.name} text={content.text} delimiter={delimiter} />
303 ) : kind === "mermaid" && rendered ? (
304 <Suspense fallback={<FileSurfaceLoading />}>
305 <MermaidFilePreview text={content.text} />
306 </Suspense>
307 ) : kind === "markdown" && rendered ? (
308 <ScrollArea className="min-h-0 flex-1">
309 <ChatMarkdown
310 text={maskMarkdownFrontmatter(content.text)}
311 cwd={undefined}
312 className="mx-auto max-w-4xl px-6 py-5"
313 />
314 </ScrollArea>
315 ) : (
316 <ReadOnlySourcePreview name={props.name} text={content.text} wordWrap={wordWrap} />
317 )
318 ) : kind === "archive" || kind === "sqlite" ? (
319 <BinaryFilePreview
320 key={revision}
321 src={url}
322 name={props.name}
323 kind={kind}
324 onRetry={() => setRevision((value) => value + 1)}
325 />
326 ) : kind === "font" ? (
327 <Suspense fallback={<FileSurfaceLoading />}>
328 <FontFilePreview src={url} name={props.name} />
329 </Suspense>
330 ) : officeKind ? (
331 <Suspense fallback={<FileSurfaceLoading />}>
332 <OfficeFilePreview
333 src={url}
334 name={props.name}
335 kind={officeKind}
336 sizeBytes={props.sizeBytes}
337 />
338 </Suspense>
339 ) : kind === "pdf" || kind === "html" ? (
340 <BrowserDocumentFrame
341 src={url}
342 title={props.name}
343 pdf={kind === "pdf"}
344 htmlRender={props.htmlRender === true}
345 threadRef={props.threadRef}
346 // Each mount signs or creates a new URL, so the URL cannot identify the file across visits.
347 // A draft keeps its Blob key after upload, so finishing the upload does not reload the PDF.
348 documentKey={
349 kind !== "pdf"
350 ? undefined
351 : props.file
352 ? blobKey(props.file)
353 : props.asset && "attachmentId" in props.asset
354 ? `${props.asset.environmentId}:${props.asset.attachmentId}`
355 : undefined
356 }
357 />
358 ) : kind === "audio" ? (
359 <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} />
360 ) : kind === "video" ? (
361 <div className="flex min-h-0 flex-1 items-center justify-center bg-black">
362 <video
363 controls
364 playsInline
365 src={url}
366 aria-label={props.name}
367 className="max-h-full max-w-full"
368 onError={() => setError("Unable to load video.")}
369 />
370 </div>
371 ) : kind === "image" ? (
372 <PreviewImage
373 key={url}
374 src={url}
375 name={props.name}
376 mimeType={props.mimeType}
377 onError={() => setError("Unable to load image.")}
378 />
379 ) : (
380 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-6 text-center">
381 <p className="text-sm font-medium">No preview for this file</p>
382 <p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
383 Save it to open in an app that supports {props.name.split(".").at(-1) || "this format"}{" "}
384 files.
385 </p>
386 </div>
387 );
388​
389 return (
390 <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
391 <div className={cn(FILE_SURFACE_SUBHEADER_CLASS)} data-surface-subheader>
392 <div className="flex min-w-0 flex-1 items-center text-xs">
393 <span className="shrink-0 px-0.5 text-muted-foreground">
394 {props.origin ?? "Attachment"}
395 </span>
396 <ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
397 <span aria-current="page" className="min-w-0 truncate px-0.5 font-medium text-foreground">
398 {props.name}
399 </span>
400 {props.sizeBytes > 0 ? (
401 <span className="ml-2 shrink-0 text-muted-foreground">
402 {formatAttachmentSize(props.sizeBytes)}
403 </span>
404 ) : null}
405 </div>
406 {renderedMode ? (
407 <FileSurfaceAction
408 label={renderedToggleLabel(renderedMode, rendered)}
409 pressed={rendered}
410 onPress={() => setRendered((value) => !value)}
411 >
412 {rendered ? (
413 <Code2Icon className="size-3.5" />
414 ) : renderedMode === "table" ? (
415 <Table2Icon className="size-3.5" />
416 ) : (
417 <EyeIcon className="size-3.5" />
418 )}
419 </FileSurfaceAction>
420 ) : null}
421 {showsRawText ? (
422 <FileSurfaceAction
423 label={wordWrap ? "Disable word wrap" : "Enable word wrap"}
424 pressed={wordWrap}
425 onPress={() => setWordWrapOverride(!wordWrap)}
426 >
427 <WrapTextIcon className="size-3.5" />
428 </FileSurfaceAction>
429 ) : null}
430 {content || streamsText ? (
431 <FileSurfaceAction
432 label={isCopied ? "Copied" : content?.truncated ? "Copy preview" : "Copy contents"}
433 onPress={() => {
434 if (content) {
435 void copyToClipboard(content.text, undefined);
436 return;
437 }
438 const copy = copyContents.current;
439 if (!copy) {
440 toastManager.add({
441 type: "error",
442 title: "Could not copy file",
443 description: "Wait for the file to finish loading before copying.",
444 });
445 return;
446 }
447 void copy()
448 .then((text) => copyToClipboard(text, undefined))
449 .catch((cause: unknown) =>
450 toastManager.add({
451 type: "error",
452 title: "Could not copy file",
453 description: cause instanceof Error ? cause.message : "Try again.",
454 }),
455 );
456 }}
457 >
458 {isCopied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
459 </FileSurfaceAction>
460 ) : null}
461 {url ? (
462 <FileSurfaceAction
463 label={saving ? "Preparing file…" : "Save file"}
464 disabled={saving}
465 onPress={save}
466 >
467 <DownloadIcon className="size-3.5" />
468 </FileSurfaceAction>
469 ) : null}
470 {props.onRemove ? (
471 <FileSurfaceAction label="Remove from draft" onPress={props.onRemove}>
472 <Trash2Icon className="size-3.5" />
473 </FileSurfaceAction>
474 ) : null}
475 {props.onClose ? (
476 <FileSurfaceAction label="Close" onPress={props.onClose}>
477 <XIcon className="size-3.5" />
478 </FileSurfaceAction>
479 ) : null}
480 </div>
481 {content?.truncated ? (
482 <FileSurfaceNotice>
483 Preview limited to the first 1 MB
484 {props.sizeBytes > 0 ? ` of a ${props.sizeBytes.toLocaleString()} byte file` : ""}. Save
485 the file to read it in full.
486 </FileSurfaceNotice>
487 ) : null}
488 {body}
489 </div>
490 );