1import { EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
3
4import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key";
5import type { DraftComposerAttachment } from "./composerImages";
6
7export interface ComposerAttachmentUploadRequest {
8 readonly environmentId: EnvironmentId;
9 readonly attachment: DraftComposerAttachment;
10}
11
12export type ComposerAttachmentUploadState =
13 | { readonly status: "uploading"; readonly progress: number }
14 | { readonly status: "ready" }
15 | { readonly status: "failed"; readonly reason: string };
16
17export function composerAttachmentUploadKey(
18 environmentId: EnvironmentId,
19 attachmentId: string,
20): string {
21 return `${environmentId}:${attachmentId}`;
22}
23
24/**
25 * Which environment a composer draft belongs to. Thread drafts carry it in
26 * the key; pending-task editor drafts borrow it from the queued message;
27 * new-task drafts carry it in their project stamp (legacy project-keyed
28 * new-task drafts still parse from the key until they are migrated on load).
29 */
30export function composerDraftEnvironmentId(
31 draftKey: string,
32 queuedMessages: ReadonlyArray<{
33 readonly messageId: string;
34 readonly environmentId: EnvironmentId;
35 }>,
36 draft?: { readonly project?: { readonly environmentId: EnvironmentId } },
37): EnvironmentId | null {
38 if (draftKey.startsWith("pending-task:")) {
39 return (
40 queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey)
41 ?.environmentId ?? null
42 );
43 }
44 if (draftKey.startsWith("new-task:")) {
45 if (draft?.project) {
46 return draft.project.environmentId;
47 }
48 const legacy = parseLegacyNewTaskDraftKey(draftKey);
49 return legacy === null ? null : EnvironmentId.make(legacy.environmentId);
50 }
51 const separator = draftKey.lastIndexOf(":");
52 return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null;
53}
54
55type UploadServerConfig = {
56 readonly environment: {
57 readonly capabilities: Pick<
58 ServerConfig["environment"]["capabilities"],
59 "attachmentUploads" | "fileAttachments"
60 >;
61 };
62};
63
64export function canUploadComposerAttachment(
65 attachment: DraftComposerAttachment,
66 config: UploadServerConfig | null | undefined,
67): boolean {
68 const capabilities = config?.environment.capabilities;
69 return (
70 capabilities?.attachmentUploads === true &&
71 (attachment.type === "image" ||
72 (capabilities.fileAttachments !== undefined &&
73 attachment.sizeBytes <=
74 clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes)))
75 );
76}
77
78/**
79 * Only a failed upload blocks sending: the outbox drain would hit the same
80 * failure, so the user has to retry or remove the file first. An upload still
81 * in flight does not block; the message queues and the drain reuses the
82 * finished upload (or re-sends the local bytes) when it delivers.
83 */
84export function composerAttachmentUploadBlockReason(input: {
85 readonly environmentId: EnvironmentId;
86 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
87 readonly connected: boolean;
88 readonly serverConfig: UploadServerConfig | null;
89 readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
90}): string | null {
91 if (!input.connected) return null;
92 for (const attachment of input.attachments) {
93 if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue;
94 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
95 if (state?.status === "failed") return "Retry or remove the failed attachment";
96 }
97 return null;
98}
99
100/** Whether any attachment the environment accepts is still being uploaded. */
101export function composerAttachmentsStillUploading(input: {
102 readonly environmentId: EnvironmentId;
103 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
104 readonly serverConfig: UploadServerConfig | null;
105 readonly states: Readonly<Record<string, ComposerAttachmentUploadState>>;
106}): boolean {
107 return input.attachments.some((attachment) => {
108 if (!canUploadComposerAttachment(attachment, input.serverConfig)) return false;
109 const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)];
110 return state?.status !== "ready" && state?.status !== "failed";
111 });
112}
113
114/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */
115export function createComposerAttachmentUploadQueue(options: {
116 readonly upload: (
117 request: ComposerAttachmentUploadRequest,
118 signal: AbortSignal,
119 onProgress: (progress: number) => void,
120 ) => Promise<boolean>;
121 readonly onChange: (states: Readonly<Record<string, ComposerAttachmentUploadState>>) => void;
122}) {
123 const jobs = new Map<
124 string,
125 { readonly controller: AbortController; readonly done: Promise<void> }
126 >();
127 let desired = new Map<string, ComposerAttachmentUploadRequest>();
128 let states: Readonly<Record<string, ComposerAttachmentUploadState>> = {};
129 let disposed = false;
130
131 function setState(key: string, state: ComposerAttachmentUploadState | undefined) {
132 const previous = states[key];
133 if (
134 previous === state ||
135 (previous?.status === "uploading" &&
136 state?.status === "uploading" &&
137 previous.progress === state.progress)
138 )
139 return;
140 const next = { ...states };
141 if (state) next[key] = state;
142 else delete next[key];
143 states = next;
144 options.onChange(states);
145 }
146
147 function pump() {
148 if (disposed) return;
149 for (const [key, request] of desired) {
150 if (jobs.size >= 3) break;
151 if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed")
152 continue;
153 const controller = new AbortController();
154 setState(key, { status: "uploading", progress: 0 });
155 // Publish the job before starting async work, including synchronous test transports.
156 const done = Promise.resolve()
157 .then(() =>
158 options.upload(request, controller.signal, (progress) => {
159 if (controller.signal.aborted) return;
160 setState(key, {
161 status: "uploading",
162 progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20,
163 });
164 }),
165 )
166 .then((persisted) => {
167 if (!controller.signal.aborted && desired.has(key)) {
168 if (!persisted) desired.delete(key);
169 setState(key, persisted ? { status: "ready" } : undefined);
170 }
171 })
172 .catch((error: unknown) => {
173 if (!controller.signal.aborted && desired.has(key)) {
174 setState(key, {
175 status: "failed",
176 reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.",
177 });
178 }
179 })
180 .finally(() => {
181 jobs.delete(key);
182 pump();
183 });
184 jobs.set(key, { controller, done });
185 }
186 }
187
188 return {
189 sync(requests: ReadonlyArray<ComposerAttachmentUploadRequest>) {
190 if (disposed) return;
191 desired = new Map(
192 requests.map((request) => [
193 composerAttachmentUploadKey(request.environmentId, request.attachment.id),
194 request,
195 ]),
196 );
197 for (const [key, job] of jobs) {
198 if (!desired.has(key)) job.controller.abort();
199 }
200 for (const key of Object.keys(states)) {
201 if (!desired.has(key)) setState(key, undefined);
202 }
203 for (const key of desired.keys()) {
204 if (!states[key]) setState(key, { status: "uploading", progress: 0 });
205 }
206 pump();
207 },
208 retry(environmentId: EnvironmentId, attachmentId: string) {
209 const key = composerAttachmentUploadKey(environmentId, attachmentId);
210 if (states[key]?.status !== "failed") return;
211 setState(key, undefined);
212 pump();
213 },
214 /** Waits for the current transfers, useful for shutdown and focused verification. */
215 async settled() {
216 while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done));
217 },
218 dispose() {
219 disposed = true;
220 desired.clear();
221 for (const job of jobs.values()) job.controller.abort();
222 states = {};
223 options.onChange(states);
224 },
225 };
226}