| 1 | import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; |
| 4 | import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; |
| 5 | import { fetch } from "expo/fetch"; |
| 6 | import { File } from "expo-file-system"; |
| 7 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { Alert } from "react-native"; |
| 9 | |
| 10 | import type { FileBackedComposerAttachment } from "./composerImages"; |
| 11 | import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; |
| 12 | import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; |
| 13 | import { useRefreshAssetUrl } from "../state/assets"; |
| 14 | import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; |
| 15 | |
| 16 | const 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. */ |
| 19 | const 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 | */ |
| 26 | export 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 | } |