1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import { Atom, AsyncResult } from "effect/reactivity";
3import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import * as Dialog from "../components/ui/Dialog";
5import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview";
6import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
7import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview";
8import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
9import { fetch } from "expo/fetch";
10import { File } from "expo-file-system";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";
14import { useEffect, useRef, useState } from "react";
15
16import type { FileBackedComposerAttachment } from "./composerImages";
17import { localAttachmentPreviewAtom, localAttachmentPreviewState } from "./localAttachmentPreview";
18import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload";
19import { refreshAssetUrl, useFreshAssetUrl, useRefreshAssetUrl } from "../state/assets";
20import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation";
21
22const isLocalUri = (uri: string) => /^(file|content):/.test(uri);
23
24/** Signed asset URLs live for an hour; treat anything older than this as worth re-minting. */
25const STALE_URL_MS = 5 * 60_000;
26
27interface AttachmentTextKey {
28 readonly uri: string;
29 /** When a remote `uri` was signed; null for local files. */
30 readonly authorizedAt: number | null;
31 readonly environmentId: EnvironmentId | null;
32 readonly resource: AssetResource;
33 readonly sizeBytes: number;
34 readonly revision: number;
35}
36
37async function readAttachmentText(key: AttachmentTextKey, signal: AbortSignal) {
38 if (isLocalUri(key.uri)) {
39 return readFilePreviewResponse({ ok: true, body: new File(key.uri).readableStream() }, signal);
40 }
41 // Reauthorize before source reads; the opening URL may have expired.
42 let target = key.uri;
43 if (key.authorizedAt === null || Date.now() - key.authorizedAt > STALE_URL_MS) {
44 const refreshed =
45 key.environmentId === null
46 ? null
47 : await refreshAssetUrl(key.environmentId, key.resource, signal).catch(() => null);
48 if (!refreshed) throw new Error("Reconnect to this environment and try again.");
49 target = refreshed;
50 }
51 const response = await fetch(target, {
52 signal,
53 headers: {
54 ...(key.sizeBytes > 0 ? { Range: `bytes=0-${FILE_TEXT_PREVIEW_MAX_BYTES}` } : {}),
55 ...(key.revision > 0 ? { "Cache-Control": "no-cache" } : {}),
56 },
57 });
58 return readFilePreviewResponse(response, signal);
59}
60
61class AttachmentTextReadError extends Schema.TaggedError<AttachmentTextReadError>()(
62 "AttachmentTextReadError",
63 {
64 message: Schema.String,
65 cause: Schema.Defect(),
66 },
67) {}
68
69// Plain-object keys compare structurally (Effect `Equal`/`Hash`). Unmounting the
70// last reader interrupts the read, which aborts the fetch.
71const attachmentTextAtom = Atom.family((key: AttachmentTextKey) =>
72 Atom.make(
73 Effect.tryPromise({
74 try: (signal) => readAttachmentText(key, signal),
75 catch: (cause) =>
76 new AttachmentTextReadError({
77 message: cause instanceof Error ? cause.message : "Could not read this file.",
78 cause,
79 }),
80 }),
81 ).pipe(Atom.withLabel("attachment-text")),
82);
83
84const noAttachmentText = Atom.make(
85 AsyncResult.initial<{ text: string; truncated: boolean }, AttachmentTextReadError>(),
86).pipe(Atom.withLabel("attachment-text:none"));
87
88/** Captured bytes never resolve against the workspace. */
89export function useAttachmentDocument(input: {
90 readonly name: string;
91 readonly mimeType: string;
92 readonly sizeBytes: number;
93 readonly attachmentId: string;
94 readonly environmentId: EnvironmentId | null;
95 readonly attachment: FileBackedComposerAttachment | null;
96}) {
97 const kind = filePreviewKind(input, { richDocuments: false });
98 const delimiter = filePreviewDelimiter(input);
99 const shareController = useRef<AbortController | null>(null);
100 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- aborts the in-flight attachment share download when the component unmounts
101 useEffect(() => () => shareController.current?.abort(), []);
102 const resource = {
103 _tag: "attachment" as const,
104 attachmentId: input.attachmentId,
105 fileName: input.name,
106 mimeType: input.mimeType,
107 disposition: "inline" as const,
108 };
109 const environmentId = input.attachment ? null : input.environmentId;
110 const refresh = useRefreshAssetUrl(environmentId, resource);
111 const [rendered, setRendered] = useState(true);
112 const [revision, setRevision] = useState(0);
113 const [sharing, setSharing] = useState(false);
114 const attachment = input.attachment;
115 const localPreviewAtom = localAttachmentPreviewAtom(attachment);
116 const localPreview = localAttachmentPreviewState(useAtomValue(localPreviewAtom));
117 const refreshLocalPreview = useAtomRefresh(localPreviewAtom);
118 const remote = useFreshAssetUrl(environmentId, attachment ? null : resource, revision);
119 const remoteUrl = AsyncResult.isSuccess(remote) && !remote.waiting ? remote.value : null;
120 const uri = attachment ? localPreview.uri : (remoteUrl?.url ?? null);
121 const needsText = kind === "text" || kind === "markdown" || (kind === "html" && !rendered);
122 const text = useAtomValue(
123 uri && needsText
124 ? attachmentTextAtom({
125 uri,
126 authorizedAt: remoteUrl?.authorizedAt ?? null,
127 environmentId,
128 resource,
129 sizeBytes: input.sizeBytes,
130 revision,
131 })
132 : noAttachmentText,
133 );
134 const content = AsyncResult.isSuccess(text) ? text.value : null;
135 const contentError = AsyncResult.isFailure(text)
136 ? (Option.getOrNull(AsyncResult.error(text))?.message ?? "Could not read this file.")
137 : null;
138 const table = content && delimiter ? parseDelimitedPreview(content.text, delimiter) : null;
139 const presentation = attachmentDocumentPresentation({
140 kind,
141 hasTable: table !== null,
142 hasEnvironment: input.environmentId !== null,
143 rendered,
144 });
145 const error = attachment
146 ? localPreview.error && (localPreview.error.message || "The local file is unavailable.")
147 : AsyncResult.isFailure(remote)
148 ? (Option.getOrNull(AsyncResult.error(remote))?.message ?? "The attachment is unavailable.")
149 : null;
150 const share = async () => {
151 if (!uri || sharing) return;
152 setSharing(true);
153 const controller = new AbortController();
154 shareController.current = controller;
155 const request = {
156 attachment: { name: input.name, mimeType: input.mimeType },
157 signal: controller.signal,
158 };
159 const result = await settlePromise(async () => {
160 if (isLocalUri(uri)) await shareLocalAttachment({ ...request, uri });
161 else await downloadAndShareAttachment({ ...request, url: (await refresh()) ?? uri });
162 });
163 if (shareController.current === controller) shareController.current = null;
164 if (controller.signal.aborted) return;
165 setSharing(false);
166 if (AsyncResult.isFailure(result)) {
167 const cause = squashAtomCommandFailure(result);
168 Dialog.alert(
169 "Could not share file",
170 cause instanceof Error ? cause.message : "Please try again.",
171 );
172 }
173 };
174 return {
175 kind,
176 ...presentation,
177 uri,
178 /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */
179 resource,
180 content,
181 table,
182 error: error ?? contentError,
183 needsText,
184 rendered,
185 setRendered,
186 revision,
187 retry: () => {
188 setRevision((value) => value + 1);
189 if (attachment) refreshLocalPreview();
190 },
191 share,
192 sharing,
193 };
194}