Skip to content
Source
Sign inSign up

ZipArchiveView.tsx

815 lines27.8 KB

1import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
2import {
3 ARCHIVE_ENTRY_MAX_BYTES,
4 ARCHIVE_MAX_BYTES,
5 boundedZip,
6} from "@t3tools/client-runtime/zip/bounded-zip";
7import type { EnvironmentId } from "@t3tools/contracts";
8import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview";
9import {
10 decodeFilePreviewText,
11 filePreviewKind,
12 hostPreviewMimeTypeFromExtension,
13} from "@t3tools/shared/filePreview";
14import { LegendList } from "@legendapp/list/react-native";
15import * as Schema from "effect/Schema";
16import { fetch } from "expo/fetch";
17import { File } from "expo-file-system";
18import { useNavigation, usePreventRemove } from "@react-navigation/native";
19import { useEffect, useEffectEvent, useRef, useState } from "react";
20import { Keyboard, Pressable, ScrollView, TextInput, View } from "react-native";
21​
22import { PierreEntryIcon } from "../../components/PierreEntryIcon";
23import { AppBar } from "../../components/ui/AppBar";
24import { useContentBottomInset, useContentTopInset } from "../../components/ui/useBarInset";
25import { SymbolView } from "../../components/ui/AppSymbol";
26import { AppText as Text } from "../../components/ui/AppText";
27import { Button } from "../../components/ui/Button";
28import { EmptyState } from "../../components/ui/EmptyState";
29import { Progress } from "../../components/ui/Progress";
30import { NativeScrollView } from "../../native/NativeScrollViewMarker";
31import * as Dialog from "../../components/ui/Dialog";
32import { FilePreviewModal } from "../../components/ui/FilePreviewModal";
33import {
34 nativeViewerErrorMessage,
35 shareGeneratedAttachment,
36 writeGeneratedAttachmentFile,
37} from "../../lib/attachmentDownload";
38import { attachmentDocumentPresentation } from "../../lib/attachmentDocumentPresentation";
39import {
40 createComposerFileAttachment,
41 removePersistedComposerAttachmentFile,
42} from "../../lib/composerImages";
43import { appendComposerDraftAttachments } from "../../state/use-composer-drafts";
44import { AttachmentDocumentBody, nativeViewerKind } from "./AttachmentDocumentBody";
45import { FilePreviewLoading } from "./FilePreviewFeedback";
46import {
47 buildZipTree,
48 flattenZipTree,
49 zipDownloadTimeLeft,
50 zipFolderLabel,
51 type ZipEntry,
52 type ZipTreeRow,
53} from "./zipTree";
54​
55export function isZipFile(file: { readonly name: string; readonly mimeType: string }) {
56 const mime = file.mimeType.split(";", 1)[0]?.trim().toLowerCase();
57 return (
58 mime === "application/zip" ||
59 mime === "application/x-zip-compressed" ||
60 file.name.toLowerCase().endsWith(".zip")
61 );
63​
64interface ZipArchive {
65 readonly entries: ReadonlyArray<ZipEntry>;
66 readonly fileCount: number;
68​
69const ZipFailure = Schema.Struct({
70 title: Schema.String,
71 detail: Schema.String,
72 retry: Schema.Boolean,
73});
74type ZipFailure = typeof ZipFailure.Type;
75​
76const SAVE_ZIP_HINT = "Save the ZIP to open it in another app.";
77const TOO_LARGE: ZipFailure = {
78 title: "Too large to browse here",
79 detail: `Browsing works up to ${formatAttachmentSize(ARCHIVE_MAX_BYTES)}. ${SAVE_ZIP_HINT}`,
80 retry: false,
81};
82const CANCELLED: ZipFailure = {
83 title: "Download cancelled",
84 detail: "The ZIP wasn't downloaded. Try again to list its files.",
85 retry: true,
86};
87const NETWORK: ZipFailure = {
88 title: "Couldn't download this ZIP",
89 detail: "Check the connection to this environment and try again.",
90 retry: true,
91};
92​
93class ZipFailureError extends Schema.TaggedError<ZipFailureError>()("ZipFailureError", {
94 failure: ZipFailure,
95}) {
96 override get message(): string {
97 return this.failure.title;
98 }
100​
101const isZipFailureError = Schema.is(ZipFailureError);
102​
103function errorMessage(cause: unknown, fallback: string) {
104 return cause instanceof Error ? cause.message : fallback;
106​
107async function readArchiveBody(
108 body: ReadableStream<Uint8Array>,
109 onProgress: (receivedBytes: number) => void,
110) {
111 const reader = body.getReader();
112 const chunks: Uint8Array[] = [];
113 let size = 0;
114 try {
115 while (true) {
116 const { value, done } = await reader.read();
117 if (done) break;
118 size += value.byteLength;
119 if (size > ARCHIVE_MAX_BYTES) throw new ZipFailureError({ failure: TOO_LARGE });
120 chunks.push(value);
121 onProgress(size);
122 }
123 } finally {
124 void reader.cancel().catch(() => {});
125 }
126 const bytes = new Uint8Array(size);
127 let offset = 0;
128 for (const chunk of chunks) {
129 bytes.set(chunk, offset);
130 offset += chunk.byteLength;
131 }
132 return bytes.buffer;
134​
135async function openArchive(bytes: ArrayBuffer): Promise<ZipArchive> {
136 let files: Awaited<ReturnType<typeof boundedZip>>;
137 try {
138 files = await boundedZip(bytes);
139 } catch (cause) {
140 throw new ZipFailureError({
141 failure: {
142 title: "Couldn't browse this ZIP",
143 detail: `${errorMessage(cause, "It may be incomplete or damaged.")} ${SAVE_ZIP_HINT}`,
144 retry: false,
145 },
146 });
147 }
148 const entries = [...files].map(([path, file]): ZipEntry => {
149 const name = path.split("/").findLast(Boolean) ?? path;
150 const dot = name.lastIndexOf(".");
151 return {
152 path,
153 name,
154 isDirectory: file.directory,
155 sizeBytes: file.bytes.byteLength,
156 mimeType:
157 (dot > 0 ? hostPreviewMimeTypeFromExtension(name.slice(dot).toLowerCase()) : null) ??
158 "application/octet-stream",
159 bytes: file.bytes,
160 };
161 });
162 return { entries, fileCount: entries.filter((entry) => !entry.isDirectory).length };
164​
165type ArchiveState =
166 | { readonly status: "loading"; readonly receivedBytes: number; readonly timeLeft: string | null }
167 | { readonly status: "ready"; readonly archive: ZipArchive }
168 | { readonly status: "failed"; readonly failure: ZipFailure };
169​
170function useZipArchive(input: {
171 readonly uri: string | null;
172 readonly isLocal: boolean;
173 readonly sizeBytes: number;
174 readonly refresh: () => Promise<string | null>;
175}) {
176 const [state, setState] = useState<ArchiveState>({
177 status: "loading",
178 receivedBytes: 0,
179 timeLeft: null,
180 });
181 const [revision, setRevision] = useState(0);
182 const controller = useRef<AbortController | null>(null);
183 const { uri, isLocal, sizeBytes } = input;
184 // The parent mints a new refresh callback on every render; it must not reload the archive.
185 const refresh = useEffectEvent(input.refresh);
186 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Load archive bytes and cancel the stream on cleanup.
187 useEffect(() => {
188 if (!uri) return;
189 const abort = new AbortController();
190 controller.current = abort;
191 const startedAt = Date.now();
192 // oxlint-disable-next-line react/set-state-in-effect -- A new archive load resets its progress.
193 setState({ status: "loading", receivedBytes: 0, timeLeft: null });
194 void (async () => {
195 if (sizeBytes > ARCHIVE_MAX_BYTES) throw new ZipFailureError({ failure: TOO_LARGE });
196 if (isLocal) {
197 const file = new File(uri);
198 if ((file.size ?? 0) > ARCHIVE_MAX_BYTES) throw new ZipFailureError({ failure: TOO_LARGE });
199 return openArchive(await file.arrayBuffer());
200 }
201 const url = (await refresh()) ?? uri;
202 const response = await fetch(url, { signal: abort.signal });
203 if (!response.ok || !response.body) throw new ZipFailureError({ failure: NETWORK });
204 const bytes = await readArchiveBody(response.body, (receivedBytes) =>
205 setState({
206 status: "loading",
207 receivedBytes,
208 timeLeft: zipDownloadTimeLeft({
209 receivedBytes,
210 totalBytes: sizeBytes,
211 elapsedMs: Date.now() - startedAt,
212 }),
213 }),
214 );
215 abort.signal.throwIfAborted();
216 return openArchive(bytes);
217 })()
218 .then((archive) => {
219 if (!abort.signal.aborted) setState({ status: "ready", archive });
220 })
221 .catch((cause: unknown) => {
222 if (!abort.signal.aborted)
223 setState({
224 status: "failed",
225 failure: isZipFailureError(cause) ? cause.failure : NETWORK,
226 });
227 });
228 return () => abort.abort();
229 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reload the archive.
230 }, [uri, isLocal, sizeBytes, revision]);
231 return {
232 state,
233 retry: () => setRevision((value) => value + 1),
234 cancel: () => {
235 controller.current?.abort();
236 setState({ status: "failed", failure: CANCELLED });
237 },
238 };
240​
241function ZipTreeRowView(props: {
242 readonly row: ZipTreeRow;
243 readonly collapsed: boolean;
244 readonly onToggle: (path: string) => void;
245 readonly onOpen: (entry: ZipEntry) => void;
246}) {
247 const { node, depth } = props.row;
248 const entry = node.entry;
249 const folder = entry ? null : zipFolderLabel(node.label);
250 return (
251 <Pressable
252 accessibilityRole="button"
253 accessibilityLabel={entry ? entry.path : `${node.path} folder`}
254 accessibilityState={entry ? undefined : { expanded: !props.collapsed }}
255 onPress={() => (entry ? props.onOpen(entry) : props.onToggle(node.path))}
256 className="mx-2 h-11 flex-row items-center gap-1.5 rounded-lg pr-2.5 active:bg-subtle"
257 style={{ paddingLeft: 8 + depth * 18 }}
258 >
259 <View className="w-4 items-center">
260 {entry ? null : (
261 <SymbolView
262 name={props.collapsed ? "chevron.right" : "chevron.down"}
263 size={12}
264 tintColorClassName="accent-icon-muted"
265 type="monochrome"
266 />
267 )}
268 </View>
269 <View className="w-5 items-center">
270 <PierreEntryIcon path={node.path} kind={entry ? "file" : "directory"} size={17} />
271 </View>
272 <Text className="min-w-0 flex-1 text-[15px] text-foreground" numberOfLines={1}>
273 {folder ? (
274 <>
275 <Text className="text-[15px] text-foreground-muted">{folder.prefix}</Text>
276 {folder.name}
277 </>
278 ) : (
279 node.label
280 )}
281 </Text>
282 <Text className="text-xs text-foreground-muted">
283 {entry
284 ? formatAttachmentSize(entry.sizeBytes)
285 : props.collapsed
286 ? `${node.fileCount} ${node.fileCount === 1 ? "file" : "files"}`
287 : ""}
288 </Text>
289 </Pressable>
290 );
292​
293function ZipArchiveHeader(props: {
294 readonly name: string;
295 readonly subtitle: string;
296 readonly onSave: () => void;
297 readonly onRemove: (() => void) | null;
298}) {
299 const navigation = useNavigation();
300 return (
301 <AppBar
302 title={props.name}
303 subtitle={props.subtitle}
304 onBack={() => {
305 if (navigation.canGoBack()) navigation.goBack();
306 }}
307 actions={[
308 { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave },
309 ...(props.onRemove
310 ? [
311 {
312 accessibilityLabel: "Remove from draft",
313 icon: "trash" as const,
314 onPress: props.onRemove,
315 destructive: true,
316 overflowOnly: true,
317 },
318 ]
319 : []),
320 ]}
321 />
322 );
324​
325function ZipArchiveProgress(props: {
326 readonly receivedBytes: number;
327 readonly totalBytes: number;
328 readonly timeLeft: string | null;
329 readonly onCancel: () => void;
330}) {
331 const { timeLeft } = props;
332 return (
333 <View className="flex-1 justify-center gap-3 bg-sheet px-8">
334 <Text className="text-xl font-t3-semibold text-foreground">
335 {formatAttachmentSize(props.receivedBytes)}
336 {props.totalBytes > 0 ? (
337 <Text className="text-xl text-foreground-muted">
338 {` of ${formatAttachmentSize(props.totalBytes)}`}
339 </Text>
340 ) : null}
341 </Text>
342 <Progress
343 value={props.totalBytes > 0 ? props.receivedBytes / props.totalBytes : undefined}
344 label="ZIP download progress"
345 />
346 <Text className="text-sm text-foreground-muted">
347 {`Downloading the ZIP so its files can be listed.${timeLeft ? ` ${timeLeft} on this connection.` : ""}`}
348 </Text>
349 <Button label="Cancel" onPress={props.onCancel} />
350 </View>
351 );
353​
354function ZipArchiveFailure(props: {
355 readonly failure: ZipFailure;
356 readonly onRetry: () => void;
357 readonly onSave: () => void;
358}) {
359 return (
360 <View className="flex-1 items-center justify-center bg-sheet px-6">
361 <EmptyState
362 variant="plain"
363 title={props.failure.title}
364 detail={props.failure.detail}
365 action={
366 <View className="flex-row gap-3">
367 {props.failure.retry ? (
368 <Button tone="primary" label="Try again" onPress={props.onRetry} />
369 ) : null}
370 <Button
371 tone={props.failure.retry ? "secondary" : "primary"}
372 label="Save ZIP"
373 onPress={props.onSave}
374 />
375 </View>
376 }
377 />
378 </View>
379 );
381​
382function ZipArchiveList(props: {
383 readonly archive: ZipArchive;
384 readonly headerInset: number;
385 readonly onOpen: (entry: ZipEntry) => void;
386}) {
387 const [query, setQuery] = useState("");
388 const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
389 const { archive } = props;
390 const rows = flattenZipTree(buildZipTree(archive.entries), collapsed, query);
391 const toggle = (path: string) =>
392 setCollapsed((current) => {
393 const next = new Set(current);
394 if (!next.delete(path)) next.add(path);
395 return next;
396 });
397 const fileCount = archive.fileCount;
398 const bottomInset = useContentBottomInset();
399 return (
400 <View className="flex-1 bg-sheet">
401 <LegendList
402 renderScrollComponent={NativeScrollView}
403 contentInset={{ top: props.headerInset, bottom: bottomInset }}
404 className="flex-1"
405 data={rows}
406 keyExtractor={(row) => `${row.node.entry ? "f" : "d"}:${row.node.path}`}
407 contentInsetAdjustmentBehavior="never"
408 automaticallyAdjustsScrollIndicatorInsets
409 estimatedItemSize={44}
410 recycleItems
411 keyboardDismissMode="on-drag"
412 keyboardShouldPersistTaps="handled"
413 extraData={collapsed}
414 contentContainerStyle={{ paddingBottom: 4 }}
415 ListHeaderComponent={
416 <View className="px-4 pt-3 pb-2.5">
417 <View className="h-[38px] flex-row items-center gap-2 rounded-[10px] bg-subtle px-3">
418 <SymbolView name="magnifyingglass" size={15} tintColorClassName="accent-icon-muted" />
419 <TextInput
420 accessibilityLabel="Filter files in ZIP"
421 autoCapitalize="none"
422 autoCorrect={false}
423 placeholder={`Filter ${fileCount} ${fileCount === 1 ? "file" : "files"}`}
424 placeholderTextColorClassName="accent-placeholder"
425 cursorColorClassName="accent-focus"
426 className="min-w-0 flex-1 font-sans text-[15px] text-foreground"
427 value={query}
428 onChangeText={setQuery}
429 />
430 {query ? (
431 <Pressable
432 accessibilityRole="button"
433 accessibilityLabel="Clear filter"
434 hitSlop={10}
435 onPress={() => setQuery("")}
436 >
437 <SymbolView
438 name="xmark.circle.fill"
439 size={16}
440 tintColorClassName="accent-icon-muted"
441 />
442 </Pressable>
443 ) : null}
444 </View>
445 </View>
446 }
447 renderItem={({ item }) => (
448 <ZipTreeRowView
449 row={item}
450 collapsed={collapsed.has(item.node.path)}
451 onToggle={toggle}
452 onOpen={props.onOpen}
453 />
454 )}
455 ListEmptyComponent={
456 query ? (
457 <EmptyState
458 variant="plain"
459 title={`No files match “${query.trim()}”`}
460 detail="The filter checks file names and folder paths."
461 action={<Button label="Clear filter" onPress={() => setQuery("")} />}
462 />
463 ) : (
464 <EmptyState variant="plain" title="This ZIP is empty" detail="It has no files." />
465 )
466 }
467 />
468 </View>
469 );
471​
472export function ZipArchiveView(props: {
473 readonly name: string;
474 readonly subtitle: string;
475 readonly sizeBytes: number;
476 readonly uri: string | null;
477 readonly isLocal: boolean;
478 readonly refresh: () => Promise<string | null>;
479 readonly environmentId: EnvironmentId | null;
480 readonly draftKey: string | null;
481 readonly maxAttachmentBytes: number | undefined;
482 readonly onSave: () => void;
483 readonly onRemove: (() => void) | null;
484}) {
485 const { state, retry, cancel } = useZipArchive(props);
486 const [selection, setSelection] = useState<{
487 readonly archive: ZipArchive;
488 readonly entry: ZipEntry;
489 } | null>(null);
490 const archive = state.status === "ready" ? state.archive : null;
491 const selected = selection && selection.archive === archive ? selection.entry : null;
492 const entryOpen = selected !== null;
493 usePreventRemove(entryOpen, () => setSelection(null));
494 const headerInset = useContentTopInset();
495 return (
496 <View className="flex-1 bg-sheet">
497 {entryOpen ? null : (
498 <ZipArchiveHeader
499 name={props.name}
500 subtitle={
501 archive
502 ? `${archive.fileCount} ${archive.fileCount === 1 ? "file" : "files"}${props.sizeBytes > 0 ? ` · ${formatAttachmentSize(props.sizeBytes)}` : ""}`
503 : props.subtitle
504 }
505 onSave={props.onSave}
506 onRemove={props.onRemove}
507 />
508 )}
509 {state.status === "ready" ? (
510 <ZipArchiveList
511 archive={state.archive}
512 headerInset={headerInset}
513 onOpen={(entry) => {
514 Keyboard.dismiss();
515 setSelection({ archive: state.archive, entry });
516 }}
517 />
518 ) : (
519 <View className="flex-1" style={{ paddingTop: headerInset }}>
520 {state.status === "loading" ? (
521 props.isLocal || state.receivedBytes === 0 ? (
522 <FilePreviewLoading message="Opening ZIP..." />
523 ) : (
524 <ZipArchiveProgress
525 receivedBytes={state.receivedBytes}
526 totalBytes={props.sizeBytes}
527 timeLeft={state.timeLeft}
528 onCancel={cancel}
529 />
530 )
531 ) : (
532 <ZipArchiveFailure failure={state.failure} onRetry={retry} onSave={props.onSave} />
533 )}
534 </View>
535 )}
536 {selected ? (
537 <View className="absolute inset-0">
538 <ZipEntryView
539 key={selected.path}
540 archiveName={props.name}
541 entry={selected}
542 environmentId={props.environmentId}
543 draftKey={props.draftKey}
544 maxAttachmentBytes={props.maxAttachmentBytes}
545 onBack={() => setSelection(null)}
546 />
547 </View>
548 ) : null}
549 </View>
550 );
552​
553async function addZipEntryToDraft(input: {
554 readonly entry: ZipEntry;
555 readonly draftKey: string;
556 readonly maxBytes: number;
557 readonly signal: AbortSignal;
558}) {
559 const { entry, signal } = input;
560 const file = await writeGeneratedAttachmentFile({
561 bytes: entry.bytes,
562 attachment: { name: entry.name, mimeType: entry.mimeType },
563 });
564 try {
565 signal.throwIfAborted();
566 const attachment = await createComposerFileAttachment({
567 uri: file.uri,
568 name: entry.name,
569 mimeType: entry.mimeType,
570 sizeBytes: entry.sizeBytes,
571 maxBytes: input.maxBytes,
572 });
573 if (signal.aborted) {
574 await removePersistedComposerAttachmentFile(attachment.fileUri);
575 signal.throwIfAborted();
576 }
577 // Rejected attachments are cleaned up by the draft store.
578 if (appendComposerDraftAttachments(input.draftKey, [attachment], { appendReference: true }))
579 throw new Error("This message already has as many attachments as it can hold.");
580 } finally {
581 file.dispose();
582 }
584​
585type EntryContent =
586 | { readonly status: "loading" }
587 | { readonly status: "text"; readonly text: string; readonly truncated: boolean }
588 | { readonly status: "file"; readonly uri: string }
589 | { readonly status: "failed"; readonly title: string; readonly message: string };
590​
591function ZipEntryView(props: {
592 readonly archiveName: string;
593 readonly entry: ZipEntry;
594 readonly environmentId: EnvironmentId | null;
595 readonly draftKey: string | null;
596 readonly maxAttachmentBytes: number | undefined;
597 readonly onBack: () => void;
598}) {
599 const { entry, draftKey, maxAttachmentBytes } = props;
600 const { mimeType } = entry;
601 const previewKind = filePreviewKind({ name: entry.name, mimeType });
602 const isText = previewKind === "text" || previewKind === "markdown" || previewKind === "html";
603 // Archive HTML is shown as source: a rendered page could load its sibling files.
604 const kind = previewKind === "html" ? "text" : previewKind;
605 const viewerKind = isText || kind === "audio" ? null : (nativeViewerKind(kind) ?? "document");
606 const tooBig = entry.sizeBytes > ARCHIVE_ENTRY_MAX_BYTES;
607 const [content, setContent] = useState<EntryContent>(
608 tooBig
609 ? {
610 status: "failed",
611 title: "Too big to preview",
612 message: `Previews stop at ${formatAttachmentSize(ARCHIVE_ENTRY_MAX_BYTES)}. Save the file to open it in another app.`,
613 }
614 : { status: "loading" },
615 );
616 const [rendered, setRendered] = useState(true);
617 const [revision, setRevision] = useState(0);
618 const [viewerOpen, setViewerOpen] = useState(true);
619 const [viewerError, setViewerError] = useState<string | null>(null);
620 const [busy, setBusy] = useState<"save" | "attach" | null>(null);
621 const action = useRef<AbortController | null>(null);
622 const pendingViewerError = useRef<string | null>(null);
623 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Cancel a pending save or attach when the entry closes.
624 useEffect(() => () => action.current?.abort(), []);
625 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Write the selected entry to a cached file and release it on cleanup.
626 useEffect(() => {
627 if (tooBig) return;
628 const controller = new AbortController();
629 let release: (() => void) | undefined;
630 void (async (): Promise<EntryContent> => {
631 if (isText) return { status: "text", ...decodeFilePreviewText(entry.bytes) };
632 const file = await writeGeneratedAttachmentFile({
633 bytes: entry.bytes,
634 attachment: { name: entry.name, mimeType },
635 });
636 if (controller.signal.aborted) {
637 file.dispose();
638 controller.signal.throwIfAborted();
639 }
640 release = file.dispose;
641 return { status: "file", uri: file.uri };
642 })()
643 .then((value) => {
644 if (!controller.signal.aborted) setContent(value);
645 })
646 .catch((cause: unknown) => {
647 if (!controller.signal.aborted)
648 setContent({
649 status: "failed",
650 title: "Couldn't read this file",
651 message: errorMessage(cause, "Could not read this file."),
652 });
653 });
654 return () => {
655 controller.abort();
656 release?.();
657 };
658 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reread the entry.
659 }, [entry, isText, mimeType, tooBig, revision]);
660​
661 const run = (kind: "save" | "attach", task: (signal: AbortSignal) => Promise<unknown>) => {
662 if (busy) return;
663 setBusy(kind);
664 const controller = new AbortController();
665 action.current = controller;
666 void task(controller.signal).then(
667 () => {
668 if (controller.signal.aborted) return;
669 setBusy(null);
670 if (kind === "attach")
671 Dialog.alert("Added to chat", `${entry.name} is attached to your message.`);
672 },
673 (cause: unknown) => {
674 if (controller.signal.aborted) return;
675 setBusy(null);
676 Dialog.alert(
677 kind === "save" ? "Could not save file" : "Could not add file to chat",
678 errorMessage(cause, "Please try again."),
679 );
680 },
681 );
682 };
683 const save = () =>
684 run("save", (signal) =>
685 shareGeneratedAttachment({
686 bytes: entry.bytes,
687 attachment: { name: entry.name, mimeType },
688 signal,
689 }),
690 );
691 const canAddToChat = draftKey !== null && maxAttachmentBytes !== undefined;
692 const addToChat = () => {
693 if (draftKey === null || maxAttachmentBytes === undefined) return;
694 run("attach", (signal) =>
695 addZipEntryToDraft({ entry, draftKey, maxBytes: maxAttachmentBytes, signal }),
696 );
697 };
698 const openViewer = () => {
699 setViewerError(null);
700 setViewerOpen(true);
701 };
702​
703 const text = content.status === "text" ? content : null;
704 const delimiter = filePreviewDelimiter({ name: entry.name, mimeType });
705 const table = text && delimiter ? parseDelimitedPreview(text.text, delimiter) : null;
706 const uri = content.status === "file" ? content.uri : null;
707 const { renderedMode, activeMode } = attachmentDocumentPresentation({
708 kind,
709 hasTable: table !== null,
710 hasEnvironment: props.environmentId !== null,
711 rendered,
712 });
713 const folders = entry.path.split("/").slice(0, -1);
714 const subtitle = `${[props.archiveName, ...folders].join(" / ")} · ${formatAttachmentSize(entry.sizeBytes)}`;
715 return (
716 <View className="flex-1 bg-sheet">
717 <AppBar
718 title={entry.name}
719 subtitle={subtitle}
720 onBack={props.onBack}
721 menus={[
722 {
723 title: "File actions",
724 icon: "ellipsis",
725 items: [
726 ...(renderedMode
727 ? [
728 {
729 id: "toggle-rendered",
730 title: rendered ? "Show source" : "Show preview",
731 icon: rendered ? "doc.text" : "eye",
732 onPress: () => setRendered((value) => !value),
733 },
734 ]
735 : []),
736 ...(canAddToChat
737 ? [
738 {
739 id: "add-to-chat",
740 title: "Add to chat",
741 icon: "plus.bubble",
742 disabled: busy !== null,
743 onPress: addToChat,
744 },
745 ]
746 : []),
747 {
748 id: "save",
749 title: "Save file",
750 icon: "square.and.arrow.up",
751 disabled: busy !== null,
752 onPress: save,
753 },
754 ],
755 },
756 ]}
757 />
758 {content.status === "failed" ? (
759 <ScrollView contentContainerClassName="flex-grow items-center justify-center px-6">
760 <EmptyState
761 variant="plain"
762 title={content.title}
763 detail={content.message}
764 action={
765 <Button tone="primary" label="Save file" loading={busy === "save"} onPress={save} />
766 }
767 />
768 </ScrollView>
769 ) : viewerKind && uri && !viewerOpen ? (
770 // Closing the viewer stays here so Save and Add to chat remain reachable.
771 <View className="flex-1 items-center justify-center bg-sheet px-6">
772 <EmptyState
773 title={viewerError ? "No preview for this file" : entry.name}
774 detail={viewerError ?? "Open it in the file viewer, save it, or add it to chat."}
775 actionLabel="Open in file viewer"
776 onAction={openViewer}
777 />
778 </View>
779 ) : (
780 <AttachmentDocumentBody
781 document={{
782 kind,
783 activeMode,
784 uri,
785 content: text,
786 table,
787 error: null,
788 needsText: isText,
789 retry: () => setRevision((value) => value + 1),
790 revision,
791 }}
792 name={entry.name}
793 environmentId={props.environmentId}
794 htmlRender={false}
795 nativeViewer={viewerKind ? (uri ? "open" : "pending") : null}
796 nativeError={null}
797 onOpenNative={openViewer}
798 />
799 )}
800 {viewerKind && uri && viewerOpen ? (
801 <FilePreviewModal
802 source={{ kind: viewerKind, name: entry.name, mimeType, uri }}
803 onOpenError={(error) => {
804 pendingViewerError.current = nativeViewerErrorMessage(error);
805 }}
806 onRequestClose={() => {
807 setViewerError(pendingViewerError.current);
808 pendingViewerError.current = null;
809 setViewerOpen(false);
810 }}
811 />
812 ) : null}
813 </View>
814 );