feat: add user color customization and metadata handling

- Implemented `useUserColor` hook to manage user profile color stored in avatar PNG metadata.
- Added `useOtherUserColor` hook to fetch and cache colors from other users' avatars.
- Created utility functions for reading and writing PNG metadata, enabling color embedding and extraction.
- Refactored `SearchResultGroup`, `RoomInput`, `Message`, `RoomPinMenu`, and `Notifications` components to utilize user color for display.
- Introduced `UserColorPicker` component in settings for users to select and save their profile color.
- Enhanced notification and message rendering to prioritize custom user colors over legacy colors.
This commit is contained in:
2026-02-06 05:53:37 +11:00
parent 4ca4af0e8b
commit 516000a25f
11 changed files with 1061 additions and 222 deletions

View File

@@ -0,0 +1,201 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { embedColorInPNG, extractColorFromPNG, fetchAndExtractColor, removeColorFromPNG } from '../utils/pngMetadata';
/**
* Fetches the user's current avatar as raw PNG data
*/
async function fetchAvatarData(
mx: MatrixClient,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!url) return null;
try {
const response = await fetch(url);
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
/**
* Hook to manage the user's chosen profile color, stored in avatar PNG metadata
* The color is embedded in the PNG tEXt chunk and syncs via the avatar
* @returns The current color, a setter function, and loading state
*/
export function useUserColor(): [
string | undefined,
(color: string | undefined) => Promise<void>,
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [color, setColor] = useState<string | undefined>();
const [loading, setLoading] = useState(true);
// Extract color from current avatar on mount and when profile changes
useEffect(() => {
const loadColor = async () => {
setLoading(true);
try {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
setColor(undefined);
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setColor(undefined);
setLoading(false);
return;
}
const extractedColor = await fetchAndExtractColor(httpUrl);
setColor(extractedColor);
} catch (e) {
console.error('Failed to load user color from avatar:', e);
setColor(undefined);
}
setLoading(false);
};
loadColor();
}, [mx, useAuthentication]);
const updateColor = useCallback(async (newColor: string | undefined) => {
const userId = mx.getUserId();
if (!userId) return;
try {
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
// No avatar to embed color in
console.warn('Cannot set color: no avatar image set');
throw new Error('No avatar set');
}
// Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) {
console.error('Failed to fetch current avatar');
throw new Error('Failed to fetch avatar');
}
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
// Modify PNG metadata
let newAvatarData: Uint8Array | null;
if (newColor) {
newAvatarData = embedColorInPNG(avatarData, newColor);
} else {
newAvatarData = removeColorFromPNG(avatarData);
}
if (!newAvatarData) {
console.error('Failed to modify avatar PNG metadata - not a valid PNG?');
throw new Error('Failed to modify PNG metadata');
}
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
// Verify the color was embedded correctly before uploading
const verifyColor = extractColorFromPNG(newAvatarData);
console.log('Verification - embedded color:', verifyColor);
if (newColor && verifyColor !== newColor) {
console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
throw new Error('Color embedding verification failed');
}
// Upload modified avatar
const blob = new Blob([newAvatarData], { type: 'image/png' });
const uploadResponse = await mx.uploadContent(blob, {
name: 'avatar.png',
type: 'image/png',
});
console.log('Uploaded new avatar:', uploadResponse.content_uri);
// Update profile with new avatar
await mx.setAvatarUrl(uploadResponse.content_uri);
setColor(newColor);
console.log('User color updated in avatar:', newColor);
} catch (e) {
console.error('Failed to update user color in avatar:', e);
throw e;
}
}, [mx, useAuthentication]);
return [color, updateColor, loading];
}
// Cache for user colors to avoid refetching for each message
const userColorCache = new Map<string, { color: string | undefined; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Hook to get another user's chosen profile color from their avatar metadata
* @param userId - The user ID to get color for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's chosen color or undefined
*/
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [color, setColor] = useState<string | undefined>();
useEffect(() => {
if (!avatarMxc) {
setColor(undefined);
return;
}
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userColorCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
setColor(cached.color);
return;
}
const loadColor = async () => {
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!httpUrl) {
setColor(undefined);
return;
}
const extractedColor = await fetchAndExtractColor(httpUrl);
// Cache the result
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
setColor(extractedColor);
};
loadColor();
}, [mx, useAuthentication, avatarMxc, userId]);
return color;
}