1import * as Dialog from "../components/ui/Dialog";
2import { useRef } from "react";
3import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ServerConfig } from "@t3tools/contracts";
4import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
5import { nextPastedTextFileName } from "@t3tools/client-runtime/text-paste";
6import type { ComposerTextPaste } from "../components/ComposerEditor";
7import {
8 convertPastedImagesToAttachments,
9 createPastedTextComposerAttachment,
10 pickComposerFiles,
11 pickComposerMedia,
12 removePersistedComposerAttachmentFile,
13 type DraftComposerAttachment,
14} from "../lib/composerImages";
15import {
16 appendComposerDraftAttachments,
17 captureComposerDraftInsertion,
18 countComposerDraftAttachmentsAfterSelection,
19 getComposerDraftSnapshot,
20 type ComposerDraftInsertion,
21} from "./use-composer-drafts";
22import { startComposerDraftOperation } from "./composerDraftOperations";
23
24export function useComposerAttachmentActions(props: {
25 readonly draftKey: string | null;
26 readonly serverConfig: ServerConfig | null | undefined;
27 readonly locked?: boolean;
28}) {
29 const reservedNames = useRef(new Map<string, Set<string>>());
30 const capabilities = props.serverConfig?.environment.capabilities;
31 const append = async (
32 key: string,
33 signal: AbortSignal,
34 attachments: ReadonlyArray<DraftComposerAttachment>,
35 insertion: ComposerDraftInsertion,
36 ) => {
37 if (signal.aborted) {
38 await Promise.all(
39 attachments.flatMap((item) =>
40 item.fileUri ? [removePersistedComposerAttachmentFile(item.fileUri)] : [],
41 ),
42 );
43 return 0;
44 }
45 return appendComposerDraftAttachments(key, attachments, { appendReference: true, insertion });
46 };
47 // A picker may finish after its draft was discarded or the composer moved off it.
48 const withDraftOperation = async (key: string, work: (signal: AbortSignal) => Promise<void>) => {
49 const operation = startComposerDraftOperation(key);
50 await work(operation.signal).finally(operation.finish);
51 };
52 const report = (title: string, error: string | null | undefined, rejected: number) => {
53 const problems = [
54 ...(error ? [error] : []),
55 ...(rejected
56 ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`]
57 : []),
58 ];
59 if (problems.length) Dialog.alert(title, problems.join("\n\n"));
60 };
61 const onPickDraftMedia = async () => {
62 const key = props.draftKey;
63 if (!key || props.locked) return;
64 const insertion = captureComposerDraftInsertion(key);
65 await withDraftOperation(key, async (signal) => {
66 const result = await pickComposerMedia({
67 existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion),
68 maxVideoBytes: capabilities?.fileAttachments?.maxUploadBytes,
69 });
70 report(
71 "Could not attach photo or video",
72 result.error,
73 await append(key, signal, result.attachments, insertion),
74 );
75 });
76 };
77 const onPickDraftFiles = async () => {
78 const key = props.draftKey;
79 if (!key || props.locked) return;
80 const maxBytes = capabilities?.fileAttachments?.maxUploadBytes;
81 if (maxBytes === undefined) {
82 Dialog.alert("Could not attach file", "This server does not support file attachments.");
83 return;
84 }
85 const insertion = captureComposerDraftInsertion(key);
86 await withDraftOperation(key, async (signal) => {
87 const result = await pickComposerFiles({
88 existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion),
89 maxBytes,
90 });
91 report(
92 "Could not attach file",
93 result.error,
94 await append(key, signal, result.files, insertion),
95 );
96 });
97 };
98 const onNativePasteImages = async (uris: ReadonlyArray<string>) => {
99 const key = props.draftKey;
100 if (!key || props.locked || !uris.length) return;
101 const insertion = captureComposerDraftInsertion(key);
102 await withDraftOperation(key, async (signal) => {
103 try {
104 const result = await convertPastedImagesToAttachments({
105 uris,
106 existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion),
107 });
108 report(
109 "Could not paste images",
110 result.error,
111 await append(key, signal, result.attachments, insertion),
112 );
113 } catch (error) {
114 Dialog.alert(
115 "Could not paste images",
116 error instanceof Error ? error.message : "Try again.",
117 );
118 }
119 });
120 };
121 const onNativePasteText = async (paste: ComposerTextPaste) => {
122 const key = props.draftKey;
123 if (!key || props.locked) return;
124 const maxBytes = capabilities?.fileAttachments?.maxUploadBytes;
125 if (maxBytes === undefined) return;
126 const names = reservedNames.current.get(key) ?? new Set<string>();
127 reservedNames.current.set(key, names);
128 for (const attachment of getComposerDraftSnapshot(key).attachments) names.add(attachment.name);
129 const name = nextPastedTextFileName([...names]);
130 names.add(name);
131 await withDraftOperation(key, async (signal) => {
132 try {
133 const attachment = await createPastedTextComposerAttachment({
134 text: paste.text,
135 name,
136 maxBytes: clampFileAttachmentUploadBytes(maxBytes),
137 });
138 const rejected = await append(key, signal, [attachment], {
139 text: paste.value,
140 ...paste.selection,
141 });
142 if (rejected) await removePersistedComposerAttachmentFile(attachment.fileUri);
143 report("Could not attach pasted text", null, rejected);
144 } catch (error) {
145 Dialog.alert(
146 "Could not attach pasted text",
147 error instanceof Error ? error.message : "Try again.",
148 );
149 }
150 });
151 };
152 return { onPickDraftMedia, onPickDraftFiles, onNativePasteImages, onNativePasteText };
153}