Skip to content
Source
Sign inSign up

composerAttachmentFiles.ts

210 lines7.1 KB

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, videoMimeType } from "../../types";
14import { isSvgAttachment } from "@t3tools/shared/image";
15import { isWorkspaceImagePreviewPath } from "@t3tools/shared/mediaPreview";
16​
17type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image";
18​
19interface FileAttachmentCapabilityState {
20 readonly attachmentUploadsCapabilityKnown: boolean;
21 readonly supportsAttachmentUploads: boolean;
22 readonly maxFileAttachmentBytes: number | null;
24​
25const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = {
26 gif: "image/gif",
27 jpeg: "image/jpeg",
28 jpg: "image/jpeg",
29 png: "image/png",
30 webp: "image/webp",
31};
32​
33/**
34 * Some sources (drags from other apps, files piped through a shell) hand over a
35 * `File` with an empty or generic MIME type.
36 */
37function 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;
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);
52​
53export function normalizeComposerImageFileMimeType(file: File): File {
54 const inferredMimeType = inferImageMimeTypeForUnknownFile(file);
55 if (!inferredMimeType) {
56 return file;
57 }
58 return new File([file], file.name, {
59 type: inferredMimeType,
60 lastModified: file.lastModified,
61 });
63​
64export function composerImageDedupKey(
65 image: Pick<ComposerImageAttachment, "mimeType" | "sizeBytes" | "name">,
66): string {
67 // Hydrated images get fresh lastModified values, so exclude it from dedupe.
68 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
70​
71// Compression changes an image's bytes, so a compressed image answers for the file it came from.
72const composerImageSourceKeys = new WeakMap<File, string>();
73​
74/** The draft store's image dedupe key, taken from the file as pasted rather than as compressed. */
75export function composerImageSourceKey(file: File): string {
76 return (
77 composerImageSourceKeys.get(file) ??
78 composerImageDedupKey({ mimeType: file.type, sizeBytes: file.size, name: file.name || "image" })
79 );
81​
82export function rememberComposerImageSource(source: File, prepared: File): void {
83 if (prepared !== source) composerImageSourceKeys.set(prepared, composerImageSourceKey(source));
85​
86function isDngFile(file: Pick<File, "name" | "type">): boolean {
87 const mimeType = file.type.split(";", 1)[0]?.trim().toLowerCase();
88 return (
89 file.name.toLowerCase().endsWith(".dng") ||
90 mimeType === "image/dng" ||
91 mimeType === "image/x-dng" ||
92 mimeType === "image/x-adobe-dng"
93 );
95​
96export function composerFileAttachmentMimeType(file: Pick<File, "name" | "type">): string {
97 // Downstream image detection uses MIME, even for attachments staged as files.
98 if (isDngFile(file)) return "image/x-adobe-dng";
99 if (isSvgAttachment({ name: file.name, mimeType: file.type })) return "image/svg+xml";
100 return (
101 videoMimeType({ name: file.name, mimeType: file.type }) ??
102 (file.type || "application/octet-stream")
103 );
105​
106export function classifyComposerAttachmentFile(
107 file: Pick<File, "name" | "type">,
108): ComposerAttachmentFileKind {
109 if (isSvgAttachment({ name: file.name, mimeType: file.type })) return "file";
110 if (isDngFile(file)) return "file";
111 if (isHeicImageFile(file)) {
112 return "image";
113 }
114 if (inferImageMimeTypeForUnknownFile(file)) {
115 return "image";
116 }
117 if (!file.type.toLowerCase().startsWith("image/")) {
118 return "file";
119 }
120 if (isProviderSendTurnSupportedImageMimeType(file.type)) return "image";
121 // These can be previewed as files, but providers must not receive them as images.
122 return isWorkspaceImagePreviewPath(file.name) ? "file" : "unsupported-image";
124​
125export function isPreviewableComposerVideo(
126 file: ComposerFileAttachment,
127 environmentId: EnvironmentId,
128): boolean {
129 return (
130 isVideoAttachment(file) &&
131 (file.file !== null ||
132 (file.uploadedAttachmentId !== undefined && file.uploadEnvironmentId === environmentId))
133 );
135​
136export function composerOtherFilesForPresentation(
137 files: ReadonlyArray<ComposerFileAttachment>,
138 environmentId: EnvironmentId,
139 inlineFileIds: ReadonlySet<string>,
140): ComposerFileAttachment[] {
141 return files.filter(
142 (file) => !isPreviewableComposerVideo(file, environmentId) && !inlineFileIds.has(file.id),
143 );
145​
146export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null {
147 if (!input.attachmentUploadsCapabilityKnown) {
148 return PROVIDER_SEND_TURN_MAX_FILE_BYTES;
149 }
150 if (!input.supportsAttachmentUploads || input.maxFileAttachmentBytes === null) {
151 return null;
152 }
153 return clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes);
155​
156export function fileAttachmentCapabilityBlockReason(
157 input: FileAttachmentCapabilityState & {
158 readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>;
159 },
160): string | null {
161 if (input.files.length === 0) {
162 return null;
163 }
164 if (!input.attachmentUploadsCapabilityKnown) {
165 return "Waiting for the server before file attachments can send";
166 }
167 const maxFileAttachmentBytes = fileAttachmentStagingLimit(input);
168 if (maxFileAttachmentBytes === null) {
169 return "This server does not accept file attachments right now. Remove the files to send.";
170 }
171 const oversizedFile = input.files.find((file) => file.sizeBytes > maxFileAttachmentBytes);
172 if (oversizedFile) {
173 return fileAttachmentTooLargeMessage(oversizedFile.name, maxFileAttachmentBytes);
174 }
175 return null;
177​
178/** Release only unpersisted uploads; hydrated files still need their server copies. */
179export function attachmentsToReleaseOnUploadCapabilityLoss(
180 attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>,
181): Array<ComposerImageAttachment | ComposerFileAttachment> {
182 return attachments.filter(
183 (attachment) => !(attachment.type === "file" && attachment.uploadedAttachmentId !== undefined),
184 );
186​
187export function shouldHandleComposerAttachmentPaste(input: {
188 readonly files: ReadonlyArray<File>;
189 readonly plainText: string;
190}): boolean {
191 if (
192 input.files.some((file) => {
193 if (isDngFile(file)) return true;
194 const classification = classifyComposerAttachmentFile(file);
195 return (
196 classification === "image" ||
197 classification === "unsupported-image" ||
198 isSvgAttachment({ name: file.name, mimeType: file.type })
199 );
200 })
201 ) {
202 return true;
203 }
204​
205 if (input.plainText.length > 0) {
206 return false;
207 }
208​
209 return input.files.some((file) => classifyComposerAttachmentFile(file) === "file");