Skip to content

Commit 087496d

Browse files
BradGrouxdm-builder
authored andcommitted
fix(desktop): crop and downscale agent avatar uploads
Co-authored-by: Brad Groux <bradgroux@hotmail.com> Signed-off-by: Brad Groux <bradgroux@hotmail.com>
1 parent 752cbfc commit 087496d

4 files changed

Lines changed: 151 additions & 9 deletions

File tree

desktop/src/features/agents/ui/AgentCreationPreview.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
2727
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
2828
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
29+
import { downscaleSquareImageToDataUrl } from "@/features/profile/lib/downscaleSquareImage";
2930
import { cn } from "@/shared/lib/cn";
3031
import { Button } from "@/shared/ui/button";
3132
import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider";
@@ -53,7 +54,7 @@ export function AgentCreationPreview({
5354
onCommitAvatar,
5455
onUploadPendingChange,
5556
onSelectAvatar,
56-
processImage,
57+
processImage = downscaleSquareImageToDataUrl,
5758
shape = "circle",
5859
testIdPrefix = "agent-avatar",
5960
variant = "default",

desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as React from "react";
33
import { toast } from "sonner";
44

55
import { AgentCreationPreview } from "@/features/agents/ui/AgentCreationPreview";
6-
import { downscaleIconToDataUrl } from "@/features/communities/lib/downscaleIcon";
76
import {
87
communityIconQueryKey,
98
useActiveCommunityIcon,
@@ -111,7 +110,6 @@ export function CommunityIconSettingsCard({
111110
onClearAvatar={clearIconPreview}
112111
onCommitAvatar={persistIcon}
113112
onSelectAvatar={previewIcon}
114-
processImage={downscaleIconToDataUrl}
115113
shape="rounded-square"
116114
testIdPrefix="community-icon"
117115
variant={compact ? "compact" : "default"}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { downscaleSquareImageToDataUrl } from "./downscaleSquareImage.ts";
5+
6+
async function withImageEnvironment({ bitmap, context, toDataURL }, run) {
7+
const bitmapDescriptor = Object.getOwnPropertyDescriptor(
8+
globalThis,
9+
"createImageBitmap",
10+
);
11+
const documentDescriptor = Object.getOwnPropertyDescriptor(
12+
globalThis,
13+
"document",
14+
);
15+
const canvas = {
16+
height: 0,
17+
width: 0,
18+
getContext: () => context,
19+
toDataURL,
20+
};
21+
Object.defineProperty(globalThis, "createImageBitmap", {
22+
configurable: true,
23+
value: async () => bitmap,
24+
});
25+
Object.defineProperty(globalThis, "document", {
26+
configurable: true,
27+
value: {
28+
createElement: (tag) => {
29+
assert.equal(tag, "canvas");
30+
return canvas;
31+
},
32+
},
33+
});
34+
try {
35+
await run(canvas);
36+
} finally {
37+
if (bitmapDescriptor) {
38+
Object.defineProperty(globalThis, "createImageBitmap", bitmapDescriptor);
39+
} else {
40+
delete globalThis.createImageBitmap;
41+
}
42+
if (documentDescriptor) {
43+
Object.defineProperty(globalThis, "document", documentDescriptor);
44+
} else {
45+
delete globalThis.document;
46+
}
47+
}
48+
}
49+
50+
test("center-crops and downsizes a landscape image to 128px WebP", async () => {
51+
const drawCalls = [];
52+
const encodes = [];
53+
let closed = false;
54+
const bitmap = {
55+
height: 200,
56+
width: 400,
57+
close: () => {
58+
closed = true;
59+
},
60+
};
61+
const context = {
62+
imageSmoothingQuality: "low",
63+
drawImage: (...args) => drawCalls.push(args),
64+
};
65+
66+
await withImageEnvironment(
67+
{
68+
bitmap,
69+
context,
70+
toDataURL: (type, quality) => {
71+
encodes.push([type, quality]);
72+
return "data:image/webp;base64,processed";
73+
},
74+
},
75+
async (canvas) => {
76+
assert.equal(
77+
await downscaleSquareImageToDataUrl({ name: "avatar.png" }),
78+
"data:image/webp;base64,processed",
79+
);
80+
assert.equal(canvas.width, 128);
81+
assert.equal(canvas.height, 128);
82+
},
83+
);
84+
85+
assert.equal(context.imageSmoothingQuality, "high");
86+
assert.deepEqual(drawCalls, [[bitmap, 100, 0, 200, 200, 0, 0, 128, 128]]);
87+
assert.deepEqual(encodes, [["image/webp", 0.85]]);
88+
assert.equal(closed, true);
89+
});
90+
91+
test("falls back to PNG when the WebView cannot encode WebP", async () => {
92+
const formats = [];
93+
const bitmap = { height: 120, width: 120, close() {} };
94+
const context = { drawImage() {}, imageSmoothingQuality: "low" };
95+
96+
await withImageEnvironment(
97+
{
98+
bitmap,
99+
context,
100+
toDataURL: (type) => {
101+
formats.push(type);
102+
return type === "image/png"
103+
? "data:image/png;base64,processed"
104+
: "data:image/png;base64,webp-unsupported";
105+
},
106+
},
107+
async () => {
108+
assert.equal(
109+
await downscaleSquareImageToDataUrl({ name: "avatar.png" }),
110+
"data:image/png;base64,processed",
111+
);
112+
},
113+
);
114+
115+
assert.deepEqual(formats, ["image/webp", "image/png"]);
116+
});
117+
118+
test("closes the decoded bitmap when canvas processing fails", async () => {
119+
let closed = false;
120+
const bitmap = {
121+
height: 120,
122+
width: 120,
123+
close: () => {
124+
closed = true;
125+
},
126+
};
127+
128+
await withImageEnvironment(
129+
{
130+
bitmap,
131+
context: null,
132+
toDataURL: () => "",
133+
},
134+
async () => {
135+
await assert.rejects(
136+
downscaleSquareImageToDataUrl({ name: "avatar.png" }),
137+
/Could not process that image/,
138+
);
139+
},
140+
);
141+
142+
assert.equal(closed, true);
143+
});

desktop/src/features/communities/lib/downscaleIcon.ts renamed to desktop/src/features/profile/lib/downscaleSquareImage.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/**
2-
* Downscale an image file to a small square data-URL for use as a community
3-
* icon. The result is inlined into the kind:9033 command (and the NIP-11
4-
* document the relay serves) so it renders
5-
* across communities without cross-relay media fetches; the relay caps icon
6-
* data-URLs at 96 KB, and 128px WebP/PNG output stays far under that.
2+
* Center-crop an image file to a small square data URL for avatars and icons.
3+
* The 128px WebP/PNG output is compact enough for inline profile data while
4+
* retaining enough detail for every current avatar surface.
75
*/
86

97
const ICON_SIZE = 128;
108

11-
export async function downscaleIconToDataUrl(file: File): Promise<string> {
9+
export async function downscaleSquareImageToDataUrl(
10+
file: File,
11+
): Promise<string> {
1212
const bitmap = await createImageBitmap(file);
1313
try {
1414
const side = Math.min(bitmap.width, bitmap.height);

0 commit comments

Comments
 (0)