From e44af7f62b2d4caef3882e9ea8f82a3b8a338c72 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:11:54 -0500 Subject: [PATCH] fix(desktop): crop and downscale agent avatar uploads Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- .../agents/ui/AgentCreationPreview.tsx | 4 +- .../ui/CommunityIconSettingsCard.tsx | 2 - .../profile/lib/downscaleSquareImage.test.mjs | 143 ++++++++++++++++++ .../lib/downscaleSquareImage.ts} | 12 +- 4 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/profile/lib/downscaleSquareImage.test.mjs rename desktop/src/features/{communities/lib/downscaleIcon.ts => profile/lib/downscaleSquareImage.ts} (67%) diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index e7a68211dc..24efa7567e 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -27,6 +27,7 @@ import { } from "@/features/profile/ui/ProfileAvatarEditor.utils"; import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel"; import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; +import { downscaleSquareImageToDataUrl } from "@/features/profile/lib/downscaleSquareImage"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; @@ -55,7 +56,7 @@ export function AgentCreationPreview({ onCommitAvatar, onUploadPendingChange, onSelectAvatar, - processImage, + processImage = downscaleSquareImageToDataUrl, shape = "circle", testIdPrefix = "agent-avatar", variant = "default", @@ -127,7 +128,6 @@ export function AgentCreationPreview({ }, processImage, }); - useEmojiMartStyles( emojiPickerContainerRef, isAvatarMenuOpen && activeTab === "emoji", diff --git a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx index a9fed1648e..9030cb35f2 100644 --- a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx +++ b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { toast } from "sonner"; import { AgentCreationPreview } from "@/features/agents/ui/AgentCreationPreview"; -import { downscaleIconToDataUrl } from "@/features/communities/lib/downscaleIcon"; import { communityIconQueryKey, useActiveCommunityIcon, @@ -111,7 +110,6 @@ export function CommunityIconSettingsCard({ onClearAvatar={clearIconPreview} onCommitAvatar={persistIcon} onSelectAvatar={previewIcon} - processImage={downscaleIconToDataUrl} shape="rounded-square" testIdPrefix="community-icon" variant={compact ? "compact" : "default"} diff --git a/desktop/src/features/profile/lib/downscaleSquareImage.test.mjs b/desktop/src/features/profile/lib/downscaleSquareImage.test.mjs new file mode 100644 index 0000000000..026c4cba54 --- /dev/null +++ b/desktop/src/features/profile/lib/downscaleSquareImage.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { downscaleSquareImageToDataUrl } from "./downscaleSquareImage.ts"; + +async function withImageEnvironment({ bitmap, context, toDataURL }, run) { + const bitmapDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "createImageBitmap", + ); + const documentDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "document", + ); + const canvas = { + height: 0, + width: 0, + getContext: () => context, + toDataURL, + }; + Object.defineProperty(globalThis, "createImageBitmap", { + configurable: true, + value: async () => bitmap, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + createElement: (tag) => { + assert.equal(tag, "canvas"); + return canvas; + }, + }, + }); + try { + await run(canvas); + } finally { + if (bitmapDescriptor) { + Object.defineProperty(globalThis, "createImageBitmap", bitmapDescriptor); + } else { + delete globalThis.createImageBitmap; + } + if (documentDescriptor) { + Object.defineProperty(globalThis, "document", documentDescriptor); + } else { + delete globalThis.document; + } + } +} + +test("center-crops and downsizes a landscape image to 128px WebP", async () => { + const drawCalls = []; + const encodes = []; + let closed = false; + const bitmap = { + height: 200, + width: 400, + close: () => { + closed = true; + }, + }; + const context = { + imageSmoothingQuality: "low", + drawImage: (...args) => drawCalls.push(args), + }; + + await withImageEnvironment( + { + bitmap, + context, + toDataURL: (type, quality) => { + encodes.push([type, quality]); + return "data:image/webp;base64,processed"; + }, + }, + async (canvas) => { + assert.equal( + await downscaleSquareImageToDataUrl({ name: "avatar.png" }), + "data:image/webp;base64,processed", + ); + assert.equal(canvas.width, 128); + assert.equal(canvas.height, 128); + }, + ); + + assert.equal(context.imageSmoothingQuality, "high"); + assert.deepEqual(drawCalls, [[bitmap, 100, 0, 200, 200, 0, 0, 128, 128]]); + assert.deepEqual(encodes, [["image/webp", 0.85]]); + assert.equal(closed, true); +}); + +test("falls back to PNG when the WebView cannot encode WebP", async () => { + const formats = []; + const bitmap = { height: 120, width: 120, close() {} }; + const context = { drawImage() {}, imageSmoothingQuality: "low" }; + + await withImageEnvironment( + { + bitmap, + context, + toDataURL: (type) => { + formats.push(type); + return type === "image/png" + ? "data:image/png;base64,processed" + : "data:image/png;base64,webp-unsupported"; + }, + }, + async () => { + assert.equal( + await downscaleSquareImageToDataUrl({ name: "avatar.png" }), + "data:image/png;base64,processed", + ); + }, + ); + + assert.deepEqual(formats, ["image/webp", "image/png"]); +}); + +test("closes the decoded bitmap when canvas processing fails", async () => { + let closed = false; + const bitmap = { + height: 120, + width: 120, + close: () => { + closed = true; + }, + }; + + await withImageEnvironment( + { + bitmap, + context: null, + toDataURL: () => "", + }, + async () => { + await assert.rejects( + downscaleSquareImageToDataUrl({ name: "avatar.png" }), + /Could not process that image/, + ); + }, + ); + + assert.equal(closed, true); +}); diff --git a/desktop/src/features/communities/lib/downscaleIcon.ts b/desktop/src/features/profile/lib/downscaleSquareImage.ts similarity index 67% rename from desktop/src/features/communities/lib/downscaleIcon.ts rename to desktop/src/features/profile/lib/downscaleSquareImage.ts index b79507988a..bfe2a8c3c5 100644 --- a/desktop/src/features/communities/lib/downscaleIcon.ts +++ b/desktop/src/features/profile/lib/downscaleSquareImage.ts @@ -1,14 +1,14 @@ /** - * Downscale an image file to a small square data-URL for use as a community - * icon. The result is inlined into the kind:9033 command (and the NIP-11 - * document the relay serves) so it renders - * across communities without cross-relay media fetches; the relay caps icon - * data-URLs at 96 KB, and 128px WebP/PNG output stays far under that. + * Center-crop an image file to a small square data URL for avatars and icons. + * The 128px WebP/PNG output is compact enough for inline profile data while + * retaining enough detail for every current avatar surface. */ const ICON_SIZE = 128; -export async function downscaleIconToDataUrl(file: File): Promise { +export async function downscaleSquareImageToDataUrl( + file: File, +): Promise { const bitmap = await createImageBitmap(file); try { const side = Math.min(bitmap.width, bitmap.height);