1import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import * as Dialog from "../components/ui/Dialog";
3import type { MediaActionsSource } from "../lib/mediaActionsSource";
4import { useNavigation } from "@react-navigation/native";
5import {
6 formatFilePathForClipboard,
7 type MediaActionId,
8} from "@t3tools/client-runtime/media-actions";
9import {
10 AuthFilesystemReadScope,
11 type EnvironmentId,
12 sessionGrantsScope,
13 type SessionGrantInput,
14} from "@t3tools/contracts";
15import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks";
16import { AsyncResult } from "effect/reactivity";
17import * as Haptics from "expo-haptics";
18import { useEffect, useRef, useState } from "react";
19
20import { useRefreshAssetUrl } from "./assets";
21import { appAtomRegistry } from "./atom-registry";
22import { useEnvironmentQuery } from "./query";
23import { environmentSession } from "./session";
24import {
25 downloadAndShareAttachment,
26 downloadAttachmentForPreview,
27 shareLocalAttachment,
28} from "../lib/attachmentDownload";
29import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
30import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
31import { canSaveImageToLibrary, saveImageToLibrary } from "../lib/saveImageToLibrary";
32
33function allowsHostMedia(session: SessionGrantInput | null) {
34 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
35}
36
37function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
38 if (environmentId === null) return true;
39 const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
40 return result._tag !== "Failure" && allowsHostMedia(AsyncResult.getOrElse(result, () => null));
41}
42
43export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) {
44 const navigation = useNavigation();
45 const hostEnvironmentId =
46 source &&
47 "resource" in source &&
48 (source.resource._tag === "workspace-file" || source.resource._tag === "media-file")
49 ? source.environmentId
50 : null;
51 const fileSession = useEnvironmentQuery(
52 hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId),
53 );
54 const canReadMedia =
55 hostEnvironmentId === null ||
56 (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data));
57 const refresh = useRefreshAssetUrl(
58 source && "environmentId" in source ? source.environmentId : null,
59 source && "resource" in source ? source.resource : null,
60 );
61 const controller = useRef<AbortController | null>(null);
62 const [busy, setBusy] = useState<"share" | "save-image" | null>(null);
63 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- aborts the in-flight media task when the component unmounts
64 useEffect(() => () => controller.current?.abort(), []);
65
66 const run = (
67 kind: "share" | "save-image",
68 task: (source: MediaActionsSource, signal: AbortSignal) => Promise<void>,
69 ) => {
70 if (!source || controller.current || !canReadHostMedia(hostEnvironmentId)) return;
71 const request = new AbortController();
72 controller.current = request;
73 setBusy(kind);
74 return task(source, request.signal)
75 .catch((error: unknown) => {
76 if (!request.signal.aborted) {
77 Dialog.alert(
78 kind === "share" ? "Could not share file" : "Could not save image",
79 error instanceof Error ? error.message : "Try again.",
80 );
81 }
82 })
83 .finally(() => {
84 if (controller.current === request) {
85 controller.current = null;
86 if (!request.signal.aborted) setBusy(null);
87 }
88 });
89 };
90
91 const hostUri = async (source: MediaActionsSource, signal: AbortSignal) => {
92 const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh();
93 if (signal.aborted || !canReadHostMedia(hostEnvironmentId)) return null;
94 if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again.");
95 return uri;
96 };
97
98 const share = () =>
99 run("share", async (source, signal) => {
100 if ("attachment" in source) {
101 const preview = await loadLocalAttachmentPreview(source.attachment, signal);
102 if (!preview) return;
103 const shared = await settlePromise(() => preview.share(signal, source.sourceIdentifier));
104 preview.dispose();
105 if (AsyncResult.isFailure(shared)) throw squashAtomCommandFailure(shared);
106 return;
107 }
108 const uri = await hostUri(source, signal);
109 if (uri === null) return;
110 const input = {
111 attachment: { name: source.name, mimeType: source.mimeType },
112 signal,
113 sourceIdentifier: source.sourceIdentifier,
114 };
115 if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri });
116 else await downloadAndShareAttachment({ ...input, url: uri });
117 });
118
119 const localImageFile = async (source: MediaActionsSource, signal: AbortSignal) => {
120 if ("attachment" in source) return loadLocalAttachmentPreview(source.attachment, signal);
121 const uri = await hostUri(source, signal);
122 if (uri === null) return null;
123 if (/^(file|content):/i.test(uri)) return { uri, dispose: () => {} };
124 return downloadAttachmentForPreview({
125 url: uri,
126 attachment: { name: source.name, mimeType: source.mimeType },
127 signal,
128 });
129 };
130
131 const saveImage = () =>
132 run("save-image", async (source, signal) => {
133 const file = await localImageFile(source, signal);
134 if (!file) return;
135 const saved = signal.aborted
136 ? null
137 : await settlePromise(() => saveImageToLibrary(file.uri, source.mimeType, source.name));
138 file.dispose();
139 if (saved === null) return;
140 if (AsyncResult.isFailure(saved)) throw squashAtomCommandFailure(saved);
141 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
142 });
143
144 const reference = source?.reference;
145 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
146 const threadId = source && "threadId" in source ? source.threadId : undefined;
147 const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] =
148 source
149 ? [
150 ...(reference?.kind === "file"
151 ? [
152 {
153 id: "copy-full-path" as const,
154 title: "Copy full path",
155 run: () => copyTextWithHaptic(formatFilePathForClipboard(reference.path)),
156 },
157 ]
158 : []),
159 ...(relativePath && reference?.kind === "file"
160 ? [
161 {
162 id: "copy-relative-path" as const,
163 title: "Copy relative path",
164 run: () =>
165 copyTextWithHaptic(formatFilePathForClipboard(relativePath, reference.path)),
166 },
167 ]
168 : []),
169 ...(reference?.kind === "url"
170 ? [
171 {
172 id: "copy-url" as const,
173 title: "Copy URL",
174 run: () => copyTextWithHaptic(reference.url),
175 },
176 ]
177 : []),
178 ...(relativePath && "environmentId" in source && threadId !== undefined
179 ? [
180 {
181 id: "open-file" as const,
182 title: "Open in file viewer",
183 disabled: !canReadMedia,
184 run: () => {
185 if (!canReadHostMedia(hostEnvironmentId)) return;
186 onOpenFile?.();
187 navigation.navigate("ThreadFile", {
188 environmentId: String(source.environmentId),
189 threadId: String(threadId),
190 path: relativePath.split("/"),
191 });
192 },
193 },
194 ]
195 : []),
196 ...(canSaveImageToLibrary(source.mimeType)
197 ? [
198 {
199 id: "save-image" as const,
200 title: busy === "save-image" ? "Saving image…" : "Save image",
201 run: saveImage,
202 disabled: busy !== null || !canReadMedia,
203 },
204 ]
205 : []),
206 {
207 id: "save" as const,
208 title:
209 busy === "share"
210 ? "Opening share sheet…"
211 : canSaveImageToLibrary(source.mimeType)
212 ? "Share"
213 : "Save or share",
214 run: share,
215 disabled: busy !== null || !canReadMedia,
216 },
217 ]
218 : [];
219 return {
220 title: reference?.kind === "file" ? reference.path : reference?.url,
221 actions,
222 sharing: busy === "share",
223 share,
224 };
225}