feat(app): improve image picker, markdown rendering, and UI interactions
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const tauriState = vi.hoisted(() => ({
|
||||
api: null as any,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/tauri", () => ({
|
||||
getTauri: () => tauriState.api,
|
||||
}));
|
||||
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithTauriDialog,
|
||||
} from "./image-attachment-picker";
|
||||
|
||||
describe("image-attachment-picker", () => {
|
||||
beforeEach(() => {
|
||||
tauriState.api = null;
|
||||
});
|
||||
|
||||
it("normalizes a picked File into a blob source", async () => {
|
||||
const file = new File(["hello"], "picked.png", { type: "image/png" });
|
||||
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "blob:test",
|
||||
mimeType: "image/png",
|
||||
fileName: null,
|
||||
file,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source.kind).toBe("blob");
|
||||
expect(result[0]?.fileName).toBe("picked.png");
|
||||
expect(result[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("keeps filesystem picker results as file uris", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///tmp/picked.png",
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///tmp/picked.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts data urls into blob sources when no file path exists", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "data:image/png;base64,AAEC",
|
||||
mimeType: "image/png",
|
||||
fileName: "inline.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.source.kind).toBe("blob");
|
||||
expect(result[0]?.fileName).toBe("inline.png");
|
||||
expect(result[0]?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("uses the tauri dialog api when available", async () => {
|
||||
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
tauriState.api = {
|
||||
dialog: { open },
|
||||
};
|
||||
|
||||
const result = await openImagePathsWithTauriDialog();
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
title: "Attach images",
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||
});
|
||||
|
||||
it("falls back to core invoke for the tauri dialog plugin", async () => {
|
||||
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
|
||||
tauriState.api = {
|
||||
core: { invoke },
|
||||
};
|
||||
|
||||
const result = await openImagePathsWithTauriDialog();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
|
||||
options: expect.objectContaining({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
title: "Attach images",
|
||||
}),
|
||||
});
|
||||
expect(result).toEqual(["/tmp/one.png"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
export type PickedImageSource =
|
||||
| { kind: "file_uri"; uri: string }
|
||||
| { kind: "blob"; blob: Blob };
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpoImagePickerAssetLike {
|
||||
uri: string;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
const IMAGE_FILE_EXTENSIONS = [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"avif",
|
||||
"heic",
|
||||
"heif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"svg",
|
||||
];
|
||||
|
||||
function isAbsoluteWindowsPath(value: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function shouldTreatAsFileUri(uri: string): boolean {
|
||||
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
|
||||
}
|
||||
|
||||
async function blobFromUri(uri: string): Promise<Blob> {
|
||||
const response = await fetch(uri);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read picked image from '${uri}'.`);
|
||||
}
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
export async function normalizePickedImageAssets(
|
||||
assets: readonly ExpoImagePickerAssetLike[]
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
if (asset.file instanceof Blob) {
|
||||
return {
|
||||
source: { kind: "blob", blob: asset.file },
|
||||
mimeType: asset.mimeType ?? asset.file.type ?? null,
|
||||
fileName: asset.fileName ?? asset.file.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldTreatAsFileUri(asset.uri)) {
|
||||
return {
|
||||
source: { kind: "file_uri", uri: asset.uri },
|
||||
mimeType: asset.mimeType ?? null,
|
||||
fileName: asset.fileName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
|
||||
mimeType: asset.mimeType ?? null,
|
||||
fileName: asset.fileName ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
|
||||
if (!selection) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(selection) ? selection : [selection];
|
||||
}
|
||||
|
||||
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
|
||||
const tauri = getTauri();
|
||||
const options = {
|
||||
directory: false,
|
||||
multiple: true,
|
||||
filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }],
|
||||
title: "Attach images",
|
||||
};
|
||||
|
||||
const dialogOpen = tauri?.dialog?.open;
|
||||
if (typeof dialogOpen === "function") {
|
||||
return normalizeTauriDialogSelection(await dialogOpen(options));
|
||||
}
|
||||
|
||||
const invoke = tauri?.core?.invoke;
|
||||
if (typeof invoke !== "function") {
|
||||
throw new Error("Tauri dialog API is not available.");
|
||||
}
|
||||
|
||||
const result = await invoke("plugin:dialog|open", { options });
|
||||
return normalizeTauriDialogSelection(
|
||||
Array.isArray(result) || typeof result === "string" || result === null ? result : null
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
|
||||
type ImagePickerSuccessResult = ImagePicker.ImagePickerSuccessResult;
|
||||
import { isTauriEnvironment } from "@/utils/tauri";
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithTauriDialog,
|
||||
type PickedImageAttachmentInput,
|
||||
} from "@/hooks/image-attachment-picker";
|
||||
|
||||
interface UseImageAttachmentPickerResult {
|
||||
pickImages: () => Promise<ImagePickerSuccessResult | null>;
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
}
|
||||
|
||||
export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
@@ -37,6 +42,18 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
isPickingRef.current = true;
|
||||
|
||||
try {
|
||||
if (Platform.OS === "web" && isTauriEnvironment()) {
|
||||
const selectedPaths = await openImagePathsWithTauriDialog();
|
||||
if (selectedPaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return selectedPaths.map((path) => ({
|
||||
source: { kind: "file_uri" as const, uri: path },
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const hasPermission = await ensurePermission();
|
||||
if (!hasPermission) {
|
||||
return null;
|
||||
@@ -44,7 +61,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
|
||||
const pendingResult = await ImagePicker.getPendingResultAsync();
|
||||
if (pendingResult && "canceled" in pendingResult && !pendingResult.canceled) {
|
||||
return pendingResult as ImagePickerSuccessResult;
|
||||
return await normalizePickedImageAssets(pendingResult.assets);
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
@@ -57,7 +74,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
return await normalizePickedImageAssets(result.assets);
|
||||
} catch (error) {
|
||||
console.error("[ImageAttachmentPicker] Failed to pick image:", error);
|
||||
Alert.alert("Error", "Failed to select image");
|
||||
|
||||
Reference in New Issue
Block a user