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 { embedColorInImage, extractColorFromImage, removeColorFromImage, fetchAndExtractColor, detectImageFormat, getMimeType, getExtension, } from '../utils/imageMetadata'; /** * Fetches the user's current avatar as raw image data */ async function fetchAvatarData( mx: MatrixClient, avatarMxc: string, useAuthentication: boolean ): Promise { 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 image metadata * The color is embedded in PNG tEXt chunk or WebP XMP 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, boolean ] { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [color, setColor] = useState(); 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'); // Detect image format const format = detectImageFormat(avatarData); console.log('Detected image format:', format); if (format === 'unknown') { console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF'); throw new Error('Unsupported image format'); } // Modify image metadata let newAvatarData: Uint8Array | null; if (newColor) { newAvatarData = embedColorInImage(avatarData, newColor); } else { newAvatarData = removeColorFromImage(avatarData); } if (!newAvatarData) { console.error('Failed to modify avatar image metadata'); throw new Error('Failed to modify image metadata'); } console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes'); // Verify the color was embedded correctly before uploading const verifyColor = extractColorFromImage(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 with correct MIME type const mimeType = getMimeType(format); const extension = getExtension(format); const blob = new Blob([newAvatarData], { type: mimeType }); const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType, }); 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(); 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(); 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; }