1import type { MediaActionsSource } from "../lib/mediaActionsSource";
2import { useNavigation } from "@react-navigation/native";
3import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
4import {
5 AuthFilesystemReadScope,
6 type EnvironmentId,
7 sessionGrantsScope,
8 type SessionGrantInput,
9} from "@t3tools/contracts";
10import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks";
11import * as Option from "effect/Option";
12import { AsyncResult } from "effect/reactivity";
13import { useEffect, useRef, useState } from "react";
14import { Alert } from "react-native";
15
16import { useRefreshAssetUrl } from "./assets";
17import { appAtomRegistry } from "./atom-registry";
18import { useEnvironmentQuery } from "./query";
19import { environmentSession } from "./session";
20import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
21import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
22import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
23
24/** An explicit action may ask the server while its grant is still unresolved. */
25function allowsHostMedia(session: SessionGrantInput | null) {
26 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
27}
28
29function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
30 if (environmentId === null) return true;
31 const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
32 return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result)));
33}
34
35export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) {
36 const navigation = useNavigation();
37 const hostEnvironmentId =
38 source &&
39 "resource" in source &&
40 (source.resource._tag === "workspace-file" || source.resource._tag === "media-file")
41 ? source.environmentId
42 : null;
43 const fileSession = useEnvironmentQuery(
44 hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId),
45 );
46 const canReadMedia =
47 hostEnvironmentId === null ||
48 (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data));
49 const refresh = useRefreshAssetUrl(
50 source && "environmentId" in source ? source.environmentId : null,
51 source && "resource" in source ? source.resource : null,
52 );
53 const controller = useRef<AbortController | null>(null);
54 const [sharing, setSharing] = useState(false);
55 useEffect(() => () => controller.current?.abort(), []);
56
57 const share = () => {
58 if (!source || controller.current || !canReadHostMedia(hostEnvironmentId)) return;
59 const request = new AbortController();
60 controller.current = request;
61 setSharing(true);
62 return (async () => {
63 if ("attachment" in source) {
64 const preview = await loadLocalAttachmentPreview(source.attachment, request.signal);
65 if (!preview) return;
66 try {
67 await preview.share(request.signal, source.sourceIdentifier);
68 } finally {
69 preview.dispose();
70 }
71 return;
72 }
73 const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh();
74 if (request.signal.aborted || !canReadHostMedia(hostEnvironmentId)) return;
75 if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again.");
76 const input = {
77 attachment: { name: source.name, mimeType: source.mimeType },
78 signal: request.signal,
79 sourceIdentifier: source.sourceIdentifier,
80 };
81 if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri });
82 else await downloadAndShareAttachment({ ...input, url: uri });
83 })()
84 .catch((error: unknown) => {
85 if (!request.signal.aborted) {
86 Alert.alert(
87 "Could not share file",
88 error instanceof Error ? error.message : "Try again.",
89 );
90 }
91 })
92 .finally(() => {
93 if (controller.current === request) {
94 controller.current = null;
95 if (!request.signal.aborted) setSharing(false);
96 }
97 });
98 };
99
100 const reference = source?.reference;
101 const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
102 const threadId = source && "threadId" in source ? source.threadId : undefined;
103 const actions: { id: MediaActionId; title: string; run: () => void; disabled?: boolean }[] =
104 source
105 ? [
106 ...(reference?.kind === "file"
107 ? [
108 {
109 id: "copy-full-path" as const,
110 title: "Copy full path",
111 run: () => copyTextWithHaptic(reference.path),
112 },
113 ]
114 : []),
115 ...(relativePath
116 ? [
117 {
118 id: "copy-relative-path" as const,
119 title: "Copy relative path",
120 run: () => copyTextWithHaptic(relativePath),
121 },
122 ]
123 : []),
124 ...(reference?.kind === "url"
125 ? [
126 {
127 id: "copy-url" as const,
128 title: "Copy URL",
129 run: () => copyTextWithHaptic(reference.url),
130 },
131 ]
132 : []),
133 ...(relativePath && "environmentId" in source && threadId !== undefined
134 ? [
135 {
136 id: "open-file" as const,
137 title: "Open in file viewer",
138 disabled: !canReadMedia,
139 run: () => {
140 if (!canReadHostMedia(hostEnvironmentId)) return;
141 onOpenFile?.();
142 navigation.navigate("ThreadFile", {
143 environmentId: String(source.environmentId),
144 threadId: String(threadId),
145 path: relativePath.split("/"),
146 });
147 },
148 },
149 ]
150 : []),
151 {
152 id: "save" as const,
153 title: sharing ? "Opening share sheet…" : "Save or share",
154 run: share,
155 disabled: sharing || !canReadMedia,
156 },
157 ]
158 : [];
159 return {
160 title: reference?.kind === "file" ? reference.path : reference?.url,
161 actions,
162 sharing,
163 share,
164 };
165}