Fix avatar upload failing on JPEG/WebP/GIF without a profile color.

embedMetadataInImage returned null for non-PNG when there was nothing to embed, which blocked plain avatar saves after format/library churn.
This commit is contained in:
2026-07-25 20:09:22 +10:00
parent 99a294791e
commit 2603ca8e5c
5 changed files with 158 additions and 36 deletions

View File

@@ -39,12 +39,14 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import {
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
needsPngForMetadata,
convertImageDataToPng,
uint8ArrayToBlob,
} from '../../../utils/imageMetadata';
import { getCurrentAccessToken } from '../../../utils/auth';
@@ -473,7 +475,35 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSaving(true);
setError(undefined);
try {
// Fetch the raw uploaded image so we can embed existing banner/color metadata
// Re-embed existing banner/color/style into the new avatar when present
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
avatarBorderColor: profileStyle.avatarBorderColor,
gradient: profileStyle.gradient,
};
const hasMetadata = Boolean(
metadata.color ||
metadata.banner ||
metadata.avatarBorderColor ||
metadata.gradient
);
// Nothing to re-embed — use the already-uploaded MXC directly
if (!hasMetadata) {
await mx.setAvatarUrl(upload.mxc);
const userId = mx.getUserId();
if (userId) {
const user = mx.getUser(userId);
if (user && user.avatarUrl !== upload.mxc) {
user.setAvatarUrl(upload.mxc);
}
}
setAvatarFile(undefined);
await syncUserAvatar();
return;
}
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
@@ -482,30 +512,33 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
let response = await fetch(httpUrl, {
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
response = await fetch(httpUrl);
}
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
const avatarData = await response.arrayBuffer();
const format = detectImageFormat(avatarData);
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
let format = detectImageFormat(avatarData);
if (format === 'unknown') throw new Error('Unsupported avatar image format');
// Re-embed the existing banner URL and profile color into the new avatar
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
};
// Banner / border / gradient only live in PNG tEXt — convert JPEG/WebP/GIF first
if (format !== 'png' && needsPngForMetadata(metadata)) {
const pngData = await convertImageDataToPng(avatarData);
if (!pngData) throw new Error('Failed to convert avatar to PNG for metadata');
avatarData = pngData;
format = 'png';
}
const modifiedData = embedMetadataInImage(avatarData, metadata);
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([modifiedData], { type: mimeType });
const blob = uint8ArrayToBlob(modifiedData, mimeType);
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
await mx.setAvatarUrl(uploadResponse.content_uri);
@@ -525,7 +558,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}
setSaving(false);
},
[mx, useAuthentication, userBanner, userColor, syncUserAvatar]
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
);
const handleRemoveBanner = async () => {