import { useCallback, useEffect, useState } from 'react'; import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; import { getCurrentAccessToken } from '../utils/auth'; 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 { // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); let response = await fetch(url, { method: 'GET', 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('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback'); response = await fetch(url, { method: 'GET' }); } 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; } // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); setColor(extractedColor); } catch (e) { console.error('Failed to load user color from avatar:', e); setColor(undefined); } setLoading(false); }; loadColor(); // Listen for avatar changes and reload color const userId = mx.getUserId(); if (!userId) return undefined; const user = mx.getUser(userId); const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => { loadColor(); }; user?.on(UserEvent.AvatarUrl, onAvatarChange); return () => { user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); }; }, [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); // Manually sync user object to ensure event listeners are triggered const user = mx.getUser(userId); if (user && user.avatarUrl !== uploadResponse.content_uri) { user.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]; } /** * LRU Cache implementation with max size limit */ class LRUCache { private cache: Map; private maxSize: number; constructor(maxSize: number) { this.cache = new Map(); this.maxSize = maxSize; } get(key: K): V | undefined { const value = this.cache.get(key); if (value !== undefined) { // Move to end (most recently used) this.cache.delete(key); this.cache.set(key, value); } return value; } set(key: K, value: V): void { // If key exists, delete it first to re-add at end if (this.cache.has(key)) { this.cache.delete(key); } // If at max size, remove oldest (first) entry else if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; if (firstKey !== undefined) { this.cache.delete(firstKey); } } this.cache.set(key, value); } clear(): void { this.cache.clear(); } } // LRU cache for user colors to avoid refetching for each message const userColorCache = new LRUCache(100); const CACHE_TTL_MS = 30 * 60 * 1000; // 30 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(); // Check cache synchronously BEFORE initializing state to avoid flash const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : ''; const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined; const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry.color : undefined; const [color, setColor] = useState(cachedColor); useEffect(() => { if (!avatarMxc) { console.log('[useOtherUserColor] No avatarMxc provided for', userId); setColor(undefined); return; } // Check cache first const cacheKey = `${userId}:${avatarMxc}`; const cached = userColorCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color); setColor(cached.color); return; } const loadColor = async () => { const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl); if (!httpUrl) { console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc); setColor(undefined); return; } // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor); // Cache the result userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() }); setColor(extractedColor); }; loadColor(); }, [mx, useAuthentication, avatarMxc, userId]); return color; }