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<Record<string, string>> = { 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<File, "name" | "type">): 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<File, "name" | "type">,): 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<ComposerImageAttachment | ComposerFileAttachment>,): Array<ComposerImageAttachment | ComposerFileAttachment> { 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<File>; 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");}
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<Record<string, string>> = {
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<File, "name" | "type">): 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<File, "name" | "type">,
): 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<ComposerImageAttachment | ComposerFileAttachment>,
): Array<ComposerImageAttachment | ComposerFileAttachment> {
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<File>;
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");
}