From 8a2a9ecef5c5ed373a8750f0a8ec7808f4b5ea34 Mon Sep 17 00:00:00 2001 From: Nick Meinhold Date: Thu, 2 Apr 2026 00:33:11 +1100 Subject: [PATCH] fix: avatar upload display and oversized crop on HiDPI screens (#442) Two related avatar bugs: 1. getAvatarUrl() returned empty string for S3 keys, so uploaded avatars never displayed. Now constructs the full URL using NEXT_PUBLIC_STORAGE_URL and NEXT_PUBLIC_AVATAR_BUCKET_NAME, with support for both path-style (MinIO) and virtual-hosted (Tigris/AWS S3) URLs. 2. Avatar crop scaled canvas by devicePixelRatio (4x pixels on Retina), producing blobs that exceeded the 2MB upload limit even for small source images. Now caps output at 512x512 and uses quality=0.85 for toBlob(). Closes #440, closes #441 Co-authored-by: Claude --- apps/web/src/utils/helpers.ts | 18 +++++++++++++++++ .../src/views/settings/components/Avatar.tsx | 20 +++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/web/src/utils/helpers.ts b/apps/web/src/utils/helpers.ts index 648bc081..23cee68d 100644 --- a/apps/web/src/utils/helpers.ts +++ b/apps/web/src/utils/helpers.ts @@ -1,3 +1,5 @@ +import { env } from "next-runtime-env"; + export const formatToArray = ( value: string | string[] | undefined, ): string[] => { @@ -50,5 +52,21 @@ export const getAvatarUrl = (imageOrKey: string | null) => { return imageOrKey; } + // Construct URL from S3 key + const useVirtualHosted = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS") === "true"; + const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN"); + const storageUrl = env("NEXT_PUBLIC_STORAGE_URL"); + const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME"); + + if (useVirtualHosted && storageDomain && bucket) { + // Virtual-hosted style: https://{bucket}.{domain}/{key} + return `https://${bucket}.${storageDomain}/${imageOrKey}`; + } + + if (storageUrl && bucket) { + // Path-style: {storageUrl}/{bucket}/{key} + return `${storageUrl}/${bucket}/${imageOrKey}`; + } + return ""; }; diff --git a/apps/web/src/views/settings/components/Avatar.tsx b/apps/web/src/views/settings/components/Avatar.tsx index 4e068eb8..d1e24e0b 100644 --- a/apps/web/src/views/settings/components/Avatar.tsx +++ b/apps/web/src/views/settings/components/Avatar.tsx @@ -110,18 +110,17 @@ export default function Avatar({ const cropYpx = (crop.y / 100) * image.naturalHeight; const cropWpx = (crop.width / 100) * image.naturalWidth; const cropHpx = (crop.height / 100) * image.naturalHeight; - canvas.width = Math.max(1, Math.floor(cropWpx)); - canvas.height = Math.max(1, Math.floor(cropHpx)); + + // Cap output at 512x512 — avatars display at 64x64, so higher res is wasteful + // and causes "File too large" errors on HiDPI screens + const maxSize = 512; + const scale = Math.min(maxSize / cropWpx, maxSize / cropHpx, 1); + canvas.width = Math.max(1, Math.floor(cropWpx * scale)); + canvas.height = Math.max(1, Math.floor(cropHpx * scale)); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported"); - // For better quality on HiDPI screens - const pixelRatio = window.devicePixelRatio || 1; - canvas.width = canvas.width * pixelRatio; - canvas.height = canvas.height * pixelRatio; - ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingQuality = "high"; - ctx.drawImage( image, cropXpx, @@ -130,8 +129,8 @@ export default function Avatar({ cropHpx, 0, 0, - canvas.width / pixelRatio, - canvas.height / pixelRatio, + canvas.width, + canvas.height, ); const mime = selectedFile?.type ?? "image/jpeg"; @@ -139,6 +138,7 @@ export default function Avatar({ canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), mime, + 0.85, ); }); return blob;