1import { requireNativeModule } from "expo";
2import {
3 type ComposerContextClipboardFragment,
4 type ComposerContextRecord,
5 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
6 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
7 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
8 COMPOSER_CONTEXT_MAX_RECORDS,
9 type EnvironmentId,
10} from "@t3tools/contracts";
11import {
12 decodeComposerContextClipboardHtml,
13 decodeComposerContextFragment,
14 encodeComposerContextFragment,
15} from "@t3tools/shared/composerContextClipboard";
16import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
17import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
18import * as Option from "effect/Option";
19import { appAtomRegistry } from "../state/atom-registry";
20import { assetEnvironment } from "../state/assets";
21import { environmentSession } from "../state/session";
22import { downloadAttachmentForPreview } from "./attachmentDownload";
23import {
24 persistComposerAttachmentFile,
25 removePersistedComposerAttachmentFile,
26 type DraftComposerAttachment,
27} from "./composerImages";
28import { referencedComposerContext, reidentifyComposerContext } from "./composerContext";
29import { uuidv4 } from "./uuid";
30import {
31 findLocalComposerClipboardAttachment,
32 waitForComposerDraftsLoaded,
33} from "../state/use-composer-drafts";
34import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
35
36export interface NativeContextClipboard {
37 readonly text: string;
38 readonly fragment: string;
39 readonly html: string;
40}
41
42function checkAborted(signal: AbortSignal): void {
43 if (signal.aborted) throw new Error("Context import cancelled");
44}
45
46const nativeClipboard = () =>
47 requireNativeModule<{
48 writeContextClipboard: (text: string, fragment: string) => Promise<void>;
49 }>("T3ComposerEditor");
50
51export function writeComposerContextClipboard(
52 text: string,
53 fragment: ComposerContextClipboardFragment,
54): Promise<void> {
55 const encoded = encodeComposerContextFragment(fragment);
56 if (!encoded)
57 return Promise.reject(
58 new Error("This context selection is too large to copy. Select fewer items."),
59 );
60 return nativeClipboard().writeContextClipboard(text, encoded);
61}
62
63/** Copies signed source assets into owned local files; the usual upload queue handles the destination. */
64export async function importComposerContextClipboard(
65 input: NativeContextClipboard,
66 existingCount: number,
67 signal: AbortSignal,
68 existingContextCount = 0,
69) {
70 const fragment =
71 decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html);
72 if (!fragment) return null;
73 const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records });
74 if (existingContextCount + (selected?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS)
75 throw new Error("Remove some context items from the draft before pasting more.");
76 const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4);
77 const attachments: DraftComposerAttachment[] = [];
78 const records: ComposerContextRecord[] = [];
79 const failures: string[] = [];
80 try {
81 for (const record of imported.context.records) {
82 checkAborted(signal);
83 if (!("attachmentId" in record)) {
84 records.push(record);
85 continue;
86 }
87 try {
88 if (existingCount + attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS)
89 throw new Error("Attachment limit reached");
90 const file = await importAttachment(record, fragment.source.environmentId, signal);
91 attachments.push(file);
92 records.push({ ...record, attachmentId: file.id });
93 } catch (error) {
94 if (signal.aborted) throw error;
95 failures.push(record.name);
96 }
97 }
98 return {
99 text: imported.text,
100 context: { version: 1 as const, records },
101 attachments,
102 failures,
103 };
104 } catch (error) {
105 await Promise.all(
106 attachments.map((attachment) =>
107 attachment.fileUri
108 ? removePersistedComposerAttachmentFile(attachment.fileUri)
109 : Promise.resolve(),
110 ),
111 );
112 throw error;
113 }
114}
115
116async function importAttachment(
117 record: Extract<ComposerContextRecord, { attachmentId: string }>,
118 environmentId: EnvironmentId,
119 signal: AbortSignal,
120): Promise<DraftComposerAttachment> {
121 await waitForComposerDraftsLoaded();
122 checkAborted(signal);
123 const local = findLocalComposerClipboardAttachment(environmentId, record.attachmentId);
124 if (local) {
125 if (!local.fileUri && local.type === "image" && local.dataUrl)
126 return {
127 ...local,
128 id: uuidv4(),
129 uploadedAttachmentId: undefined,
130 uploadEnvironmentId: undefined,
131 };
132 if (local.fileUri) {
133 const preview = await loadLocalAttachmentPreview(
134 { ...local, fileUri: local.fileUri },
135 signal,
136 );
137 if (!preview) throw new Error("Context import cancelled");
138 try {
139 return await persistImportedAttachment(record, preview.uri, signal);
140 } finally {
141 preview.dispose();
142 }
143 }
144 }
145 const connection = appAtomRegistry.get(
146 environmentSession.preparedConnectionValueAtom(environmentId),
147 );
148 if (Option.isNone(connection)) throw new Error("Reconnect to the source environment");
149 const result = await executeAtomQuery(
150 appAtomRegistry,
151 assetEnvironment.createUrl({
152 environmentId,
153 input: {
154 resource: { _tag: "attachment", attachmentId: record.attachmentId, fileName: record.name },
155 },
156 }),
157 { refresh: true, reportFailure: false },
158 );
159 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
160 checkAborted(signal);
161 const url = resolveAssetUrl(connection.value.httpBaseUrl, result.value.relativeUrl);
162 if (!url) throw new Error("Attachment URL unavailable");
163 const temporary = await downloadAttachmentForPreview({
164 attachment: { name: record.name, mimeType: record.mimeType },
165 url,
166 signal,
167 });
168 if (!temporary) throw new Error("Attachment import cancelled");
169 try {
170 return await persistImportedAttachment(record, temporary.uri, signal);
171 } finally {
172 temporary.dispose();
173 }
174}
175
176async function persistImportedAttachment(
177 record: Extract<ComposerContextRecord, { attachmentId: string }>,
178 uri: string,
179 signal: AbortSignal,
180): Promise<DraftComposerAttachment> {
181 const fileUri = await persistComposerAttachmentFile(
182 uri,
183 record.name,
184 record.kind === "image"
185 ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
186 : PROVIDER_SEND_TURN_MAX_FILE_BYTES,
187 );
188 if (signal.aborted) {
189 await removePersistedComposerAttachmentFile(fileUri);
190 checkAborted(signal);
191 }
192 const { File } = await import("expo-file-system");
193 const common = {
194 id: uuidv4(),
195 fileUri,
196 name: record.name,
197 mimeType: record.mimeType,
198 sizeBytes: new File(fileUri).size,
199 };
200 return record.kind === "image"
201 ? { ...common, type: "image", previewUri: fileUri }
202 : { ...common, type: "file" };
203}