| 1 | import type { MediaActionsSource } from "../lib/mediaActionsSource"; |
| 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; |
| 4 | import { |
| 5 | AuthFilesystemReadScope, |
| 6 | type EnvironmentId, |
| 7 | sessionGrantsScope, |
| 8 | type SessionGrantInput, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; |
| 11 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult } from "effect/reactivity"; |
| 13 | import { useEffect, useRef, useState } from "react"; |
| 14 | import { Alert } from "react-native"; |
| 15 | |
| 16 | import { useRefreshAssetUrl } from "./assets"; |
| 17 | import { appAtomRegistry } from "./atom-registry"; |
| 18 | import { useEnvironmentQuery } from "./query"; |
| 19 | import { environmentSession } from "./session"; |
| 20 | import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; |
| 21 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; |
| 22 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 23 | |
| 24 | /** An explicit action may ask the server while its grant is still unresolved. */ |
| 25 | function allowsHostMedia(session: SessionGrantInput | null) { |
| 26 | return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); |
| 27 | } |
| 28 | |
| 29 | function 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 | |
| 35 | export 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 | } |