1import {
2 type EnvironmentId,
3 isProviderSendTurnSupportedImageMimeType,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5} from "@t3tools/contracts";
6import {
7 clampFileAttachmentUploadBytes,
8 fileAttachmentTooLargeMessage,
9} from "@t3tools/client-runtime/state/attachments";
10
11import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore";
12import { isHeicImageFile } from "../../lib/imageCompression";
13import { isVideoAttachment } from "../../types";
14
15type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";
16
17interface FileAttachmentCapabilityState {
18 readonly attachmentUploadsCapabilityKnown: boolean;
19 readonly supportsAttachmentUploads: boolean;
20 readonly maxFileAttachmentBytes: number | null;
21}
22
23const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
24 gif: "image/gif",
25 jpeg: "image/jpeg",
26 jpg: "image/jpeg",
27 png: "image/png",
28 webp: "image/webp",
29};
30
31/**
32 * Some sources (drags from other apps, files piped through a shell) hand over
33 * a `File` with an empty or generic MIME type. Maps the extension to a
34 * provider-supported image type so a plain `photo.jpg` still lands on the
35 * image path; anything unrecognized stays a generic file.
36 */
37export function inferImageMimeTypeFromName(name: string): string | null {
38 const dotIndex = name.lastIndexOf(".");
39 if (dotIndex <= 0) {
40 return null;
41 }
42 return IMAGE_MIME_TYPE_BY_EXTENSION[name.slice(dotIndex + 1).toLowerCase()] ?? null;
43}
44
45function inferImageMimeTypeForUnknownFile(file: Pick<File, "name" | "type">): string | null {
46 const mimeType = file.type.toLowerCase();
47 if (mimeType !== "" && mimeType !== "application/octet-stream") {
48 return null;
49 }
50 return inferImageMimeTypeFromName(file.name);
51}
52
53/** Give extension-recognized images a concrete type before compression. */
54export function normalizeComposerImageFileMimeType(file: File): File {
55 const inferredMimeType = inferImageMimeTypeForUnknownFile(file);
56 if (!inferredMimeType) {
57 return file;
58 }
59 return new File([file], file.name, {
60 type: inferredMimeType,
61 lastModified: file.lastModified,
62 });
63}
64
65export function classifyComposerAttachmentFile(
66 file: Pick<File, "name" | "type">,
67): ComposerAttachmentFileKind {
68 if (isHeicImageFile(file)) {
69 return "image";
70 }
71 if (inferImageMimeTypeForUnknownFile(file)) {
72 return "image";
73 }
74 if (!file.type.toLowerCase().startsWith("image/")) {
75 return "file";
76 }
77 return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image";
78}
79
80export function isPreviewableComposerVideo(
81 file: ComposerFileAttachment,
82 environmentId: EnvironmentId,
83): boolean {
84 return (
85 isVideoAttachment(file) &&
86 (file.file !== null ||
87 (file.uploadedAttachmentId !== undefined && file.uploadEnvironmentId === environmentId))
88 );
89}
90
91/** Non-media files without an inline reference still need the legacy attachment row. */
92export function composerOtherFilesForPresentation(
93 files: ReadonlyArray<ComposerFileAttachment>,
94 environmentId: EnvironmentId,
95 inlineFileIds: ReadonlySet<string>,
96): ComposerFileAttachment[] {
97 return files.filter(
98 (file) => !isPreviewableComposerVideo(file, environmentId) && !inlineFileIds.has(file.id),
99 );
100}
101
102/** Byte limit for adding a generic file to the local composer draft. */
103export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null {
104 if (!input.attachmentUploadsCapabilityKnown) {
105 return PROVIDER_SEND_TURN_MAX_FILE_BYTES;
106 }
107 if (!input.supportsAttachmentUploads || input.maxFileAttachmentBytes === null) {
108 return null;
109 }
110 return clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes);
111}
112
113/** Why retained generic files cannot send with the current server config. */
114export function fileAttachmentCapabilityBlockReason(
115 input: FileAttachmentCapabilityState & {
116 readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>;
117 },
118): string | null {
119 if (input.files.length === 0) {
120 return null;
121 }
122 if (!input.attachmentUploadsCapabilityKnown) {
123 return "Waiting for the server before file attachments can send";
124 }
125 const maxFileAttachmentBytes = fileAttachmentStagingLimit(input);
126 if (maxFileAttachmentBytes === null) {
127 return "This server does not accept file attachments right now. Remove the files to send.";
128 }
129 const oversizedFile = input.files.find((file) => file.sizeBytes > maxFileAttachmentBytes);
130 if (oversizedFile) {
131 return fileAttachmentTooLargeMessage(oversizedFile.name, maxFileAttachmentBytes);
132 }
133 return null;
134}
135
136/**
137 * When `capabilities.attachmentUploads` flips off (reconnect, version skew),
138 * tear down only uploads that have not been persisted onto a draft file.
139 * Once `uploadedAttachmentId` is stamped, the draft references that server
140 * copy after reload even if its local `File` is still available in memory.
141 * Explicit attachment removal releases persisted uploads through
142 * `releaseDraftAttachment`.
143 */
144export function attachmentsToReleaseOnUploadCapabilityLoss(
145 attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>,
146): Array<ComposerImageAttachment | ComposerFileAttachment> {
147 return attachments.filter(
148 (attachment) => !(attachment.type === "file" && attachment.uploadedAttachmentId !== undefined),
149 );
150}
151
152/**
153 * Whether a paste's files should be claimed as composer attachments instead of
154 * falling through to the default text paste. Deliberately no capacity or
155 * pending-plan-question gate here: `addComposerAttachments` owns those limits
156 * and reports them, while a gate at this layer would swallow the paste with no
157 * feedback.
158 */
159export function shouldHandleComposerAttachmentPaste(input: {
160 readonly files: ReadonlyArray<File>;
161 readonly plainText: string;
162}): boolean {
163 if (
164 input.files.some((file) => {
165 const classification = classifyComposerAttachmentFile(file);
166 return classification === "image" || classification === "unsupported-image";
167 })
168 ) {
169 return true;
170 }
171
172 if (input.plainText.length > 0) {
173 return false;
174 }
175
176 return input.files.some((file) => classifyComposerAttachmentFile(file) === "file");
177}