import { useCallback, useEffect, useState } from 'react'; import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; import { fetchAndExtractMetadata, extractMetadataFromImage, embedMetadataInImage, detectImageFormat, getMimeType, getExtension, ImageMetadata, ProfileGradient, } from '../utils/imageMetadata'; /** * Fetches the user's current avatar as raw image data */ async function fetchAvatarData( mx: ReturnType, avatarMxc: string, useAuthentication: boolean ): Promise { const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication); if (!url) return null; try { const accessToken = mx.getAccessToken(); const response = await fetch(url, { method: 'GET', headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, }); if (!response.ok) return null; return await response.arrayBuffer(); } catch { return null; } } export type ProfileStyleData = { avatarBorderColor?: string; gradient?: ProfileGradient; }; /** * Hook to manage the user's profile style (avatar border color and gradient) * Stored in avatar image metadata * @returns Current style data, a setter function, and loading state */ export function useUserProfileStyle(): [ ProfileStyleData, (style: Partial) => Promise, boolean ] { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [style, setStyle] = useState({}); const [loading, setLoading] = useState(true); // Extract style from current avatar on mount and when profile changes useEffect(() => { const loadStyle = 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) { setStyle({}); setLoading(false); return; } const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); if (!httpUrl) { setStyle({}); setLoading(false); return; } const accessToken = mx.getAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); setStyle({ avatarBorderColor: metadata.avatarBorderColor, gradient: metadata.gradient, }); } catch { setStyle({}); } setLoading(false); }; loadStyle(); // Listen for avatar changes and reload style const userId = mx.getUserId(); if (!userId) return undefined; const user = mx.getUser(userId); const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => { loadStyle(); }; user?.on(UserEvent.AvatarUrl, onAvatarChange); return () => { user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); }; }, [mx, useAuthentication]); const updateStyle = useCallback( async (newStyle: Partial) => { const userId = mx.getUserId(); if (!userId) { throw new Error('No user ID'); } const profile = await mx.getProfileInfo(userId); const avatarUrl = profile.avatar_url; if (!avatarUrl) { throw new Error('No avatar set. Please upload an avatar first.'); } // Fetch current avatar const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); if (!avatarData) { throw new Error('Failed to fetch current avatar'); } // Detect image format const format = detectImageFormat(avatarData); if (format === 'unknown') { throw new Error('Unsupported avatar image format'); } // Get existing metadata to preserve const existingMetadata = extractMetadataFromImage(avatarData); // Merge with new style const newMetadata: ImageMetadata = { ...existingMetadata, avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor, gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient, }; // Embed updated metadata const newAvatarData = embedMetadataInImage(avatarData, newMetadata); if (!newAvatarData) { throw new Error('Failed to embed style in avatar metadata'); } // 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, }); // Update profile with new avatar await mx.setAvatarUrl(uploadResponse.content_uri); // Manually sync user object const user = mx.getUser(userId); if (user && user.avatarUrl !== uploadResponse.content_uri) { user.setAvatarUrl(uploadResponse.content_uri); } // Update local state setStyle((prev) => ({ ...prev, ...newStyle, })); }, [mx, useAuthentication] ); return [style, updateStyle, loading]; } // Cache for other users' profile styles const userStyleCache = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /** * Hook to get another user's profile style from their avatar metadata * @param userId - The user ID to get style for * @param avatarMxc - The user's avatar MXC URL * @returns The user's profile style data */ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [style, setStyle] = useState({}); useEffect(() => { if (!avatarMxc) { setStyle({}); return; } const loadStyle = async () => { // Check cache first const cacheKey = `${userId}:${avatarMxc}`; const cached = userStyleCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { setStyle(cached.style); return; } const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); if (!httpUrl) { setStyle({}); return; } const accessToken = mx.getAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); const styleData: ProfileStyleData = { avatarBorderColor: metadata.avatarBorderColor, gradient: metadata.gradient, }; // Cache the result userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() }); setStyle(styleData); }; loadStyle(); }, [mx, useAuthentication, avatarMxc, userId]); return style; }