1import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview";
4import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
5import { fetch } from "expo/fetch";
6import { File } from "expo-file-system";
7import { useEffect, useMemo, useRef, useState } from "react";
8import { Alert } from "react-native";
9
10import type { FileBackedComposerAttachment } from "./composerImages";
11import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
12import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
13import { useRefreshAssetUrl } from "../state/assets";
14import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation";
15
16const isLocalUri = (uri: string) => /^(file|content):/.test(uri);
17
18/** Signed asset URLs live for an hour; treat anything older than this as worth re-minting. */
19const STALE_URL_MS = 5 * 60_000;
20
21/**
22 * Loads a captured attachment for viewing: a fresh signed URL for a sent or uploaded file,
23 * a leased local file for a draft. Text kinds read a bounded prefix; documents hand their
24 * URL to a native or web renderer. Captured bytes never resolve against the workspace.
25 */
26export function useAttachmentDocument(input: {
27 readonly name: string;
28 readonly mimeType: string;
29 readonly sizeBytes: number;
30 readonly attachmentId: string;
31 readonly environmentId: EnvironmentId | null;
32 readonly attachment: FileBackedComposerAttachment | null;
33}) {
34 const kind = filePreviewKind(input);
35 const delimiter = filePreviewDelimiter(input);
36 const shareController = useRef<AbortController | null>(null);
37 useEffect(() => () => shareController.current?.abort(), []);
38 const resource = useMemo(
39 () => ({
40 _tag: "attachment" as const,
41 attachmentId: input.attachmentId,
42 fileName: input.name,
43 mimeType: input.mimeType,
44 disposition: "inline" as const,
45 }),
46 [input.attachmentId, input.name, input.mimeType],
47 );
48 const environmentId = input.attachment ? null : input.environmentId;
49 const refresh = useRefreshAssetUrl(environmentId, resource);
50 const [localUri, setLocalUri] = useState<string | null>(null);
51 const [remoteUri, setRemoteUri] = useState<string | null>(null);
52 const [content, setContent] = useState<{ text: string; truncated: boolean } | null>(null);
53 const table = useMemo(
54 () => (content && delimiter ? parseDelimitedPreview(content.text, delimiter) : null),
55 [content, delimiter],
56 );
57 const [error, setError] = useState<string | null>(null);
58 // Reading source is a separate failure from loading the file: a rendered HTML page can be
59 // fine while its bytes are not UTF-8, and switching back to the page must not stay stuck.
60 const [contentError, setContentError] = useState<string | null>(null);
61 const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null);
62 const [rendered, setRendered] = useState(true);
63 const presentation = attachmentDocumentPresentation({
64 kind,
65 hasTable: table !== null,
66 hasEnvironment: input.environmentId !== null,
67 rendered,
68 });
69 const [revision, setRevision] = useState(0);
70 const [sharing, setSharing] = useState(false);
71 const uri = input.attachment ? localUri : remoteUri;
72 const attachment = input.attachment;
73 useEffect(() => {
74 if (attachment) return;
75 let cancelled = false;
76 // Await a fresh signed URL: cached links can expire while the client is suspended.
77 // oxlint-disable-next-line react/set-state-in-effect -- A new preview request clears its previous URL and error.
78 setRemoteUri(null);
79 setError(null);
80 textReadUrl.current = null;
81 void refresh()
82 .then((url) => {
83 if (cancelled) return;
84 if (!url) throw new Error("Reconnect to this environment and try again.");
85 textReadUrl.current = { uri: url, authorizedAt: Date.now() };
86 setRemoteUri(url);
87 })
88 .catch((cause: unknown) => {
89 if (!cancelled)
90 setError(cause instanceof Error ? cause.message : "The attachment is unavailable.");
91 });
92 return () => {
93 cancelled = true;
94 };
95 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reauthorize the remote file.
96 }, [attachment, refresh, revision]);
97 useEffect(() => {
98 if (!attachment) return;
99 // A new attachment must not keep the previous file behind it: `share()` would otherwise
100 // send the old bytes under the new name if this load fails.
101 // oxlint-disable-next-line react/set-state-in-effect -- A new attachment invalidates the last one.
102 setLocalUri(null);
103 setContent(null);
104 setContentError(null);
105 const controller = new AbortController();
106 let release: (() => void) | undefined;
107 void loadLocalAttachmentPreview(attachment, controller.signal)
108 .then((file) => {
109 if (!file) return;
110 if (controller.signal.aborted) return file.dispose();
111 release = file.dispose;
112 setLocalUri(file.uri);
113 setError(null);
114 })
115 .catch((cause: unknown) => {
116 if (!controller.signal.aborted)
117 setError(cause instanceof Error ? cause.message : "The local file is unavailable.");
118 });
119 return () => {
120 controller.abort();
121 release?.();
122 };
123 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reacquire a local file lease after a failed load.
124 }, [attachment, revision]);
125 const needsText = kind === "text" || kind === "markdown" || (kind === "html" && !rendered);
126 const sizeBytes = input.sizeBytes;
127 useEffect(() => {
128 if (!uri || !needsText) return;
129 const controller = new AbortController();
130 // oxlint-disable-next-line react/set-state-in-effect -- A new external resource must clear the previous response before loading.
131 setContent(null);
132 setContentError(null);
133 const response = isLocalUri(uri)
134 ? Promise.resolve().then(() => ({ ok: true, body: new File(uri).readableStream() }))
135 : (async () => {
136 // A signed URL minted when the file opened may have expired by the time the user
137 // switches to source; reauthorize before fetching instead of reading a stale link.
138 const authorized = textReadUrl.current;
139 let target = authorized?.uri ?? uri;
140 if (!authorized || Date.now() - authorized.authorizedAt > STALE_URL_MS) {
141 const refreshed = await refresh();
142 if (!refreshed) throw new Error("Reconnect to this environment and try again.");
143 target = refreshed;
144 if (!controller.signal.aborted) {
145 // Keep the source-read URL and its age together without restarting active media.
146 textReadUrl.current = { uri: refreshed, authorizedAt: Date.now() };
147 }
148 }
149 return fetch(target, {
150 signal: controller.signal,
151 headers: {
152 ...(sizeBytes > 0 ? { Range: `bytes=0-${FILE_TEXT_PREVIEW_MAX_BYTES}` } : {}),
153 ...(revision > 0 ? { "Cache-Control": "no-cache" } : {}),
154 },
155 });
156 })();
157 void response
158 .then((value) => readFilePreviewResponse(value, controller.signal))
159 .then((value) => {
160 if (!controller.signal.aborted) setContent(value);
161 })
162 .catch((cause: unknown) => {
163 if (!controller.signal.aborted)
164 setContentError(cause instanceof Error ? cause.message : "Could not read this file.");
165 });
166 return () => controller.abort();
167 }, [uri, needsText, revision, sizeBytes, refresh]);
168 const share = async () => {
169 if (!uri || sharing) return;
170 setSharing(true);
171 const controller = new AbortController();
172 shareController.current = controller;
173 const request = {
174 attachment: { name: input.name, mimeType: input.mimeType },
175 signal: controller.signal,
176 };
177 try {
178 if (isLocalUri(uri)) await shareLocalAttachment({ ...request, uri });
179 else await downloadAndShareAttachment({ ...request, url: (await refresh()) ?? uri });
180 } catch (cause) {
181 if (controller.signal.aborted) return;
182 Alert.alert(
183 "Could not share file",
184 cause instanceof Error ? cause.message : "Please try again.",
185 );
186 } finally {
187 if (shareController.current === controller) shareController.current = null;
188 if (!controller.signal.aborted) setSharing(false);
189 }
190 };
191 return {
192 kind,
193 ...presentation,
194 uri,
195 /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */
196 resource,
197 content,
198 table,
199 error: error ?? (needsText ? contentError : null),
200 needsText,
201 rendered,
202 setRendered,
203 revision,
204 retry: () => setRevision((value) => value + 1),
205 share,
206 sharing,
207 };
208}