Compare commits
1 Commits
99a294791e
...
2603ca8e5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2603ca8e5c |
@@ -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 () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getMimeType,
|
||||
getExtension,
|
||||
ImageMetadata,
|
||||
convertImageDataToPng,
|
||||
uint8ArrayToBlob,
|
||||
} from '../utils/imageMetadata';
|
||||
|
||||
/**
|
||||
@@ -137,27 +139,39 @@ export function useUserBanner(): [
|
||||
throw new Error('Failed to fetch current avatar');
|
||||
}
|
||||
|
||||
// Detect image format
|
||||
const format = detectImageFormat(avatarData);
|
||||
|
||||
// Detect image format — banner lives in PNG tEXt, so convert other formats first
|
||||
let workingData: ArrayBuffer | Uint8Array = avatarData;
|
||||
let format = detectImageFormat(workingData);
|
||||
|
||||
if (format === 'unknown') {
|
||||
throw new Error('Unsupported avatar image format');
|
||||
}
|
||||
|
||||
if (format !== 'png') {
|
||||
console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata');
|
||||
const pngData = await convertImageDataToPng(workingData);
|
||||
if (!pngData) {
|
||||
throw new Error('Failed to convert avatar to PNG for banner metadata');
|
||||
}
|
||||
workingData = pngData;
|
||||
format = 'png';
|
||||
}
|
||||
|
||||
// Get existing metadata to preserve
|
||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
||||
const existingMetadata = extractMetadataFromImage(workingData);
|
||||
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
||||
|
||||
// Modify image metadata with new banner, preserving color
|
||||
|
||||
// Modify image metadata with new banner, preserving color.
|
||||
// Empty string clears banner (PNG embed drops falsy fields after stripping old chunks).
|
||||
const newMetadata: ImageMetadata = {
|
||||
color: existingMetadata.color,
|
||||
banner: newBanner,
|
||||
banner: newBanner ?? '',
|
||||
avatarBorderColor: existingMetadata.avatarBorderColor,
|
||||
gradient: existingMetadata.gradient,
|
||||
};
|
||||
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
||||
|
||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
||||
|
||||
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
||||
|
||||
if (!newAvatarData) {
|
||||
throw new Error('Failed to embed banner in avatar metadata');
|
||||
@@ -166,8 +180,8 @@ export function useUserBanner(): [
|
||||
// Verify the banner was embedded correctly before uploading
|
||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
||||
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
||||
|
||||
if (newBanner && verifyMetadata.banner !== newBanner) {
|
||||
|
||||
if ((newBanner || undefined) !== verifyMetadata.banner) {
|
||||
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
||||
throw new Error('Banner verification failed');
|
||||
}
|
||||
@@ -176,8 +190,8 @@ export function useUserBanner(): [
|
||||
// Upload modified avatar with correct MIME type
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
||||
|
||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||
|
||||
const uploadResponse = await mx.uploadContent(blob, {
|
||||
name: `avatar.${extension}`,
|
||||
type: mimeType,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
detectImageFormat,
|
||||
getMimeType,
|
||||
getExtension,
|
||||
uint8ArrayToBlob,
|
||||
} from '../utils/imageMetadata';
|
||||
|
||||
/**
|
||||
@@ -176,7 +177,7 @@ export function useUserColor(): [
|
||||
// Upload modified avatar with correct MIME type
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||
const uploadResponse = await mx.uploadContent(blob, {
|
||||
name: `avatar.${extension}`,
|
||||
type: mimeType,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken }
|
||||
getExtension,
|
||||
ImageMetadata,
|
||||
ProfileGradient,
|
||||
convertImageDataToPng,
|
||||
uint8ArrayToBlob,
|
||||
} from '../utils/imageMetadata';
|
||||
|
||||
/**
|
||||
@@ -142,14 +144,24 @@ export function useUserProfileStyle(): [
|
||||
throw new Error('Failed to fetch current avatar');
|
||||
}
|
||||
|
||||
// Detect image format
|
||||
const format = detectImageFormat(avatarData);
|
||||
// Border/gradient live in PNG tEXt — convert other formats first
|
||||
let workingData: ArrayBuffer | Uint8Array = avatarData;
|
||||
let format = detectImageFormat(workingData);
|
||||
if (format === 'unknown') {
|
||||
throw new Error('Unsupported avatar image format');
|
||||
}
|
||||
|
||||
if (format !== 'png') {
|
||||
const pngData = await convertImageDataToPng(workingData);
|
||||
if (!pngData) {
|
||||
throw new Error('Failed to convert avatar to PNG for style metadata');
|
||||
}
|
||||
workingData = pngData;
|
||||
format = 'png';
|
||||
}
|
||||
|
||||
// Get existing metadata to preserve
|
||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
||||
const existingMetadata = extractMetadataFromImage(workingData);
|
||||
|
||||
// Merge with new style
|
||||
const newMetadata: ImageMetadata = {
|
||||
@@ -159,7 +171,7 @@ export function useUserProfileStyle(): [
|
||||
};
|
||||
|
||||
// Embed updated metadata
|
||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
||||
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
||||
if (!newAvatarData) {
|
||||
throw new Error('Failed to embed style in avatar metadata');
|
||||
}
|
||||
@@ -167,7 +179,7 @@ export function useUserProfileStyle(): [
|
||||
// Upload modified avatar with correct MIME type
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||
|
||||
const uploadResponse = await mx.uploadContent(blob, {
|
||||
name: `avatar.${extension}`,
|
||||
|
||||
@@ -258,6 +258,59 @@ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: st
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when metadata includes fields that only PNG tEXt chunks can store.
|
||||
*/
|
||||
export function needsPngForMetadata(metadata: ImageMetadata): boolean {
|
||||
return Boolean(metadata.banner || metadata.avatarBorderColor || metadata.gradient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image data to PNG via canvas (needed for banner/border/gradient metadata).
|
||||
*/
|
||||
export async function convertImageDataToPng(
|
||||
imageData: ArrayBuffer | Uint8Array
|
||||
): Promise<Uint8Array | null> {
|
||||
const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||
const format = detectImageFormat(bytes);
|
||||
if (format === 'png') return bytes;
|
||||
if (format === 'unknown') return null;
|
||||
|
||||
try {
|
||||
const blob = uint8ArrayToBlob(bytes, getMimeType(format));
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
if (!pngBlob) return null;
|
||||
return new Uint8Array(await pngBlob.arrayBuffer());
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[convertImageDataToPng] Conversion failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Blob from Uint8Array without relying on BufferSource/BlobPart quirks
|
||||
* across TS DOM lib / Electron versions (avoids SharedArrayBuffer typing issues).
|
||||
*/
|
||||
export function uint8ArrayToBlob(data: Uint8Array, type: string): Blob {
|
||||
const copy = new ArrayBuffer(data.byteLength);
|
||||
new Uint8Array(copy).set(data);
|
||||
return new Blob([copy], { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed paarrot metadata (color and/or banner) into image data
|
||||
* Preserves existing metadata that is not being updated
|
||||
@@ -270,18 +323,27 @@ export function embedMetadataInImage(
|
||||
metadata: ImageMetadata
|
||||
): Uint8Array | null {
|
||||
const format = detectImageFormat(imageData);
|
||||
|
||||
const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||
|
||||
switch (format) {
|
||||
case 'png':
|
||||
return embedMetadataInPNG(imageData, metadata);
|
||||
// For other formats, only embed color for now
|
||||
// Non-PNG: color via format-specific chunks; banner/border/gradient need PNG (convert first)
|
||||
case 'webp':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
if (needsPngForMetadata(metadata)) {
|
||||
console.warn(
|
||||
'[embedMetadataInImage] Banner/border/gradient require PNG; convert with convertImageDataToPng first. Got:',
|
||||
format
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (metadata.color) {
|
||||
return embedColorInImage(imageData, metadata.color);
|
||||
}
|
||||
return null;
|
||||
// Supported format with nothing to embed — succeed as a no-op so avatar upload isn't blocked
|
||||
return bytes;
|
||||
default:
|
||||
console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata');
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user