import { type EnvironmentId, isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_FILE_BYTES, } from "@t3tools/contracts"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, } from "@t3tools/client-runtime/state/attachments"; import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; import { isHeicImageFile } from "../../lib/imageCompression"; import { isVideoAttachment } from "../../types"; type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image"; interface FileAttachmentCapabilityState { readonly attachmentUploadsCapabilityKnown: boolean; readonly supportsAttachmentUploads: boolean; readonly maxFileAttachmentBytes: number | null; } const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly> = { gif: "image/gif", jpeg: "image/jpeg", jpg: "image/jpeg", png: "image/png", webp: "image/webp", }; /** * Some sources (drags from other apps, files piped through a shell) hand over * a `File` with an empty or generic MIME type. Maps the extension to a * provider-supported image type so a plain `photo.jpg` still lands on the * image path; anything unrecognized stays a generic file. */ export function inferImageMimeTypeFromName(name: string): string | null { const dotIndex = name.lastIndexOf("."); if (dotIndex <= 0) { return null; } return IMAGE_MIME_TYPE_BY_EXTENSION[name.slice(dotIndex + 1).toLowerCase()] ?? null; } function inferImageMimeTypeForUnknownFile(file: Pick): string | null { const mimeType = file.type.toLowerCase(); if (mimeType !== "" && mimeType !== "application/octet-stream") { return null; } return inferImageMimeTypeFromName(file.name); } /** Give extension-recognized images a concrete type before compression. */ export function normalizeComposerImageFileMimeType(file: File): File { const inferredMimeType = inferImageMimeTypeForUnknownFile(file); if (!inferredMimeType) { return file; } return new File([file], file.name, { type: inferredMimeType, lastModified: file.lastModified, }); } export function classifyComposerAttachmentFile( file: Pick, ): ComposerAttachmentFileKind { if (isHeicImageFile(file)) { return "image"; } if (inferImageMimeTypeForUnknownFile(file)) { return "image"; } if (!file.type.toLowerCase().startsWith("image/")) { return "file"; } return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image"; } export function isPreviewableComposerVideo( file: ComposerFileAttachment, environmentId: EnvironmentId, ): boolean { return ( isVideoAttachment(file) && (file.file !== null || (file.uploadedAttachmentId !== undefined && file.uploadEnvironmentId === environmentId)) ); } /** Byte limit for adding a generic file to the local composer draft. */ export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null { if (!input.attachmentUploadsCapabilityKnown) { return PROVIDER_SEND_TURN_MAX_FILE_BYTES; } if (!input.supportsAttachmentUploads || input.maxFileAttachmentBytes === null) { return null; } return clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes); } /** Why retained generic files cannot send with the current server config. */ export function fileAttachmentCapabilityBlockReason( input: FileAttachmentCapabilityState & { readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>; }, ): string | null { if (input.files.length === 0) { return null; } if (!input.attachmentUploadsCapabilityKnown) { return "Waiting for the server before file attachments can send"; } const maxFileAttachmentBytes = fileAttachmentStagingLimit(input); if (maxFileAttachmentBytes === null) { return "This server does not accept file attachments right now. Remove the files to send."; } const oversizedFile = input.files.find((file) => file.sizeBytes > maxFileAttachmentBytes); if (oversizedFile) { return fileAttachmentTooLargeMessage(oversizedFile.name, maxFileAttachmentBytes); } return null; } /** * When `capabilities.attachmentUploads` flips off (reconnect, version skew), * tear down only uploads that have not been persisted onto a draft file. * Once `uploadedAttachmentId` is stamped, the draft references that server * copy after reload even if its local `File` is still available in memory. * Explicit attachment removal releases persisted uploads through * `releaseDraftAttachment`. */ export function attachmentsToReleaseOnUploadCapabilityLoss( attachments: ReadonlyArray, ): Array { return attachments.filter( (attachment) => !(attachment.type === "file" && attachment.uploadedAttachmentId !== undefined), ); } /** * Whether a paste's files should be claimed as composer attachments instead of * falling through to the default text paste. Deliberately no capacity or * pending-plan-question gate here: `addComposerAttachments` owns those limits * and reports them, while a gate at this layer would swallow the paste with no * feedback. */ export function shouldHandleComposerAttachmentPaste(input: { readonly files: ReadonlyArray; readonly plainText: string; }): boolean { if ( input.files.some((file) => { const classification = classifyComposerAttachmentFile(file); return classification === "image" || classification === "unsupported-image"; }) ) { return true; } if (input.plainText.length > 0) { return false; } return input.files.some((file) => classifyComposerAttachmentFile(file) === "file"); }