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:
201
src/app/hooks/useUserColor.ts
Normal file
201
src/app/hooks/useUserColor.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user