Fix image uploads using the wrong format (#2380)
* fix(app): preserve image attachment MIME types Image intake discarded clipboard and desktop path metadata, then guessed JPEG when a later file object had no type. Resolve MIME once at the platform boundary and require it downstream. * fix(app): honor native picker MIME metadata
This commit is contained in:
@@ -34,6 +34,8 @@ export async function normalizePickedImageAssets(
|
||||
return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng);
|
||||
}
|
||||
|
||||
export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise<string[]> {
|
||||
export async function pickImagesWithDesktopDialog(
|
||||
_dialog?: unknown,
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
throw new Error("Desktop dialog API is not available on native.");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host";
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithDesktopDialog,
|
||||
} from "./image-attachment-picker";
|
||||
import { normalizePickedImageAssets, pickImagesWithDesktopDialog } from "./image-attachment-picker";
|
||||
|
||||
function fakeDialogReturning(selection: string | string[] | null): {
|
||||
dialog: DesktopDialogBridge;
|
||||
@@ -42,6 +39,27 @@ describe("image-attachment-picker", () => {
|
||||
expect(result[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("derives the type of a type-less picked File from its name", async () => {
|
||||
const file = new File(["image"], "picked.png");
|
||||
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "blob:test",
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
file,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "blob", blob: file },
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps filesystem picker results as file uris", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
@@ -78,7 +96,7 @@ describe("image-attachment-picker", () => {
|
||||
it("uses the desktop dialog api when available", async () => {
|
||||
const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
|
||||
const result = await openImagePathsWithDesktopDialog(dialog);
|
||||
const result = await pickImagesWithDesktopDialog(dialog);
|
||||
|
||||
expect(recordedOptions).toHaveLength(1);
|
||||
expect(recordedOptions[0]).toMatchObject({
|
||||
@@ -86,14 +104,25 @@ describe("image-attachment-picker", () => {
|
||||
directory: false,
|
||||
title: "Attach images",
|
||||
});
|
||||
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "/tmp/one.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "one.png",
|
||||
},
|
||||
{
|
||||
source: { kind: "file_uri", uri: "/tmp/two.jpg" },
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "two.jpg",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when desktop dialog API is not available", async () => {
|
||||
await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow(
|
||||
await expect(pickImagesWithDesktopDialog(null)).rejects.toThrow(
|
||||
"Desktop dialog API is not available.",
|
||||
);
|
||||
await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow(
|
||||
await expect(pickImagesWithDesktopDialog({})).rejects.toThrow(
|
||||
"Desktop dialog API is not available.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DesktopDialogBridge } from "@/desktop/host";
|
||||
import { RASTER_IMAGE_FILE_EXTENSIONS } from "@/attachments/file-types";
|
||||
import { RASTER_IMAGE_FILE_EXTENSIONS, resolveRasterImageMimeType } from "@/attachments/file-types";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
@@ -7,7 +8,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
mimeType: string;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
@@ -30,30 +31,52 @@ async function blobFromUri(uri: string): Promise<Blob> {
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
function requirePickedImageMimeType(input: {
|
||||
mimeType?: string | null;
|
||||
path?: string | null;
|
||||
}): string {
|
||||
const mimeType = resolveRasterImageMimeType(input);
|
||||
if (!mimeType) {
|
||||
throw new Error(`Unsupported image type for '${input.path ?? "selected image"}'.`);
|
||||
}
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
export async function normalizePickedImageAssets(
|
||||
assets: readonly ExpoImagePickerAssetLike[],
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
if (asset.file instanceof Blob) {
|
||||
const fileName = asset.fileName ?? asset.file.name ?? null;
|
||||
return {
|
||||
source: { kind: "blob", blob: asset.file },
|
||||
mimeType: asset.mimeType ?? asset.file.type ?? null,
|
||||
fileName: asset.fileName ?? asset.file.name ?? null,
|
||||
mimeType: requirePickedImageMimeType({
|
||||
mimeType: asset.mimeType || asset.file.type,
|
||||
path: fileName ?? asset.uri,
|
||||
}),
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldTreatAsFileUri(asset.uri)) {
|
||||
return {
|
||||
source: { kind: "file_uri", uri: asset.uri },
|
||||
mimeType: asset.mimeType ?? null,
|
||||
mimeType: requirePickedImageMimeType({
|
||||
mimeType: asset.mimeType,
|
||||
path: asset.fileName ?? asset.uri,
|
||||
}),
|
||||
fileName: asset.fileName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const blob = await blobFromUri(asset.uri);
|
||||
return {
|
||||
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
|
||||
mimeType: asset.mimeType ?? null,
|
||||
source: { kind: "blob", blob },
|
||||
mimeType: requirePickedImageMimeType({
|
||||
mimeType: asset.mimeType || blob.type,
|
||||
path: asset.fileName ?? asset.uri,
|
||||
}),
|
||||
fileName: asset.fileName ?? null,
|
||||
};
|
||||
}),
|
||||
@@ -67,9 +90,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s
|
||||
return Array.isArray(selection) ? selection : [selection];
|
||||
}
|
||||
|
||||
export async function openImagePathsWithDesktopDialog(
|
||||
export async function pickImagesWithDesktopDialog(
|
||||
dialog: DesktopDialogBridge | null | undefined,
|
||||
): Promise<string[]> {
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
const options = {
|
||||
directory: false,
|
||||
multiple: true,
|
||||
@@ -87,5 +110,15 @@ export async function openImagePathsWithDesktopDialog(
|
||||
throw new Error("Desktop dialog API is not available.");
|
||||
}
|
||||
|
||||
return normalizeDesktopDialogSelection(await dialogOpen(options));
|
||||
return normalizeDesktopDialogSelection(await dialogOpen(options)).map((path) => {
|
||||
const mimeType = resolveRasterImageMimeType({ path });
|
||||
if (!mimeType) {
|
||||
throw new Error(`Unsupported image type for '${path}'.`);
|
||||
}
|
||||
return {
|
||||
source: { kind: "file_uri" as const, uri: path },
|
||||
mimeType,
|
||||
fileName: getFileNameFromPath(path),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +53,30 @@ describe("native image attachment picker", () => {
|
||||
expect(recordedUris).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses explicit native MIME metadata before the URI extension", async () => {
|
||||
const { exportAsPng, recordedUris } = fakeExportAsPng();
|
||||
|
||||
const result = await normalizePickedImageAssetsWith(
|
||||
[
|
||||
{
|
||||
uri: "file:///photos/screenshot.jpg",
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
],
|
||||
exportAsPng,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/screenshot.jpg" },
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
expect(recordedUris).toEqual([]);
|
||||
});
|
||||
|
||||
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
||||
const { exportAsPng, recordedUris } = fakeExportAsPng();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
mimeType: string;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
@@ -62,13 +62,15 @@ function pickedAssetSupportedFormat(
|
||||
asset: ExpoImagePickerAssetLike,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const uriExtension = extensionFromPath(asset.uri);
|
||||
if (uriExtension) {
|
||||
return supportedFormatForExtension(uriExtension);
|
||||
const uriFormat = supportedFormatForExtension(uriExtension);
|
||||
if (uriExtension && !uriFormat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
supportedFormatForMimeType(asset.mimeType) ??
|
||||
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
||||
supportedFormatForMimeType(asset.mimeType)
|
||||
uriFormat
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithDesktopDialog,
|
||||
pickImagesWithDesktopDialog,
|
||||
type PickedImageAttachmentInput,
|
||||
} from "@/hooks/image-attachment-picker";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -51,15 +51,11 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
|
||||
try {
|
||||
if (isWeb && isElectronRuntime()) {
|
||||
const selectedPaths = await openImagePathsWithDesktopDialog(getDesktopHost()?.dialog);
|
||||
if (selectedPaths.length === 0) {
|
||||
const selectedImages = await pickImagesWithDesktopDialog(getDesktopHost()?.dialog);
|
||||
if (selectedImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return selectedPaths.map((path) => ({
|
||||
source: { kind: "file_uri" as const, uri: path },
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
}));
|
||||
return selectedImages;
|
||||
}
|
||||
|
||||
const hasPermission = await ensurePermission();
|
||||
|
||||
Reference in New Issue
Block a user