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 { fetchAndExtractMetadata, extractMetadataFromImage, embedMetadataInImage, detectImageFormat, getMimeType, getExtension, ImageMetadata, convertImageDataToPng, uint8ArrayToBlob, } 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) { 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 banner, stored in avatar image metadata * The banner URL is embedded in avatar metadata and syncs via the avatar * @returns The current banner URL, a setter function, and loading state */ export function useUserBanner(): [ string | undefined, (banner: string | undefined) => Promise, boolean ] { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [banner, setBanner] = useState(); const [loading, setLoading] = useState(true); // Extract banner from current avatar on mount and when profile changes useEffect(() => { const loadBanner = async () => { setLoading(true); try { const userId = mx.getUserId(); if (!userId) { setLoading(false); return; } const profile = await mx.getProfileInfo(userId); const avatarUrl = profile.avatar_url; console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl); if (!avatarUrl) { setBanner(undefined); setLoading(false); return; } const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); if (!httpUrl) { setBanner(undefined); setLoading(false); return; } // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner); setBanner(metadata.banner); } catch { setBanner(undefined); } setLoading(false); }; loadBanner(); // Listen for avatar changes and reload banner const userId = mx.getUserId(); if (!userId) return undefined; const user = mx.getUser(userId); const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => { console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl); loadBanner(); }; user?.on(UserEvent.AvatarUrl, onAvatarChange); return () => { user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); }; }, [mx, useAuthentication]); const updateBanner = useCallback(async (newBanner: string | undefined) => { 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 — banner lives in PNG tEXt, so convert other formats first let workingData: ArrayBuffer | Uint8Array = avatarData; let format = detectImageFormat(workingData); if (format === 'unknown') { throw new Error('Unsupported avatar image format'); } if (format !== 'png') { console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata'); const pngData = await convertImageDataToPng(workingData); if (!pngData) { throw new Error('Failed to convert avatar to PNG for banner metadata'); } workingData = pngData; format = 'png'; } // Get existing metadata to preserve const existingMetadata = extractMetadataFromImage(workingData); console.log('[updateBanner] Existing metadata:', existingMetadata); // Modify image metadata with new banner, preserving color. // Empty string clears banner (PNG embed drops falsy fields after stripping old chunks). const newMetadata: ImageMetadata = { color: existingMetadata.color, banner: newBanner ?? '', avatarBorderColor: existingMetadata.avatarBorderColor, gradient: existingMetadata.gradient, }; console.log('[updateBanner] New metadata to embed:', newMetadata); const newAvatarData = embedMetadataInImage(workingData, newMetadata); if (!newAvatarData) { throw new Error('Failed to embed banner in avatar metadata'); } // Verify the banner was embedded correctly before uploading const verifyMetadata = extractMetadataFromImage(newAvatarData); console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata); if ((newBanner || undefined) !== verifyMetadata.banner) { console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner); throw new Error('Banner verification failed'); } console.log('[updateBanner] Banner verification passed'); // Upload modified avatar with correct MIME type const mimeType = getMimeType(format); const extension = getExtension(format); const blob = uint8ArrayToBlob(newAvatarData, mimeType); const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType, }); console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri); // Update profile with new avatar try { console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri); await Promise.race([ mx.setAvatarUrl(uploadResponse.content_uri), new Promise((_, reject) => { setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000); }) ]); console.log('[updateBanner] setAvatarUrl completed'); // Manually sync user object to ensure event listeners are triggered const user = mx.getUser(userId); if (user && user.avatarUrl !== uploadResponse.content_uri) { console.log('[updateBanner] Manually syncing user avatar URL'); user.setAvatarUrl(uploadResponse.content_uri); } console.log('[updateBanner] Banner update complete'); } catch (error) { throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Note: Don't set banner here - let the avatar change event handle it // setBanner(newBanner); }, [mx, useAuthentication]); return [banner, updateBanner, loading]; } // Cache for user banners to avoid refetching for each message const userBannerCache = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /** * Hook to get another user's chosen profile banner from their avatar metadata * @param userId - The user ID to get banner for * @param avatarMxc - The user's avatar MXC URL * @returns The user's chosen banner as a blob URL or undefined */ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [bannerBlobUrl, setBannerBlobUrl] = useState(); useEffect(() => { let blobUrl: string | undefined; if (!avatarMxc) { console.log('[useOtherUserBanner] No avatarMxc provided for', userId); setBannerBlobUrl(undefined); return undefined; } const loadBanner = async () => { console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc); // Check cache first const cacheKey = `${userId}:${avatarMxc}`; const cached = userBannerCache.get(cacheKey); let bannerMxc: string | undefined; if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { // Use cached banner MXC console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc); bannerMxc = cached.bannerMxc; } else { // Fetch banner MXC from avatar metadata const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl); if (!httpUrl) { console.log('[useOtherUserBanner] Failed to get HTTP URL'); setBannerBlobUrl(undefined); return; } // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); console.log('[useOtherUserBanner] Extracted metadata:', metadata); bannerMxc = metadata.banner; // Cache the banner MXC (not the blob URL) userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() }); } if (!bannerMxc) { console.log('[useOtherUserBanner] No banner MXC found'); setBannerBlobUrl(undefined); return; } console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc); // Fetch the banner image data with authentication const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication); if (!bannerHttpUrl) { console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL'); setBannerBlobUrl(undefined); return; } console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl); // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const headers: HeadersInit = {}; if (useAuthentication && accessToken) { headers.Authorization = `Bearer ${accessToken}`; } const response = await fetch(bannerHttpUrl, { headers }); if (!response.ok) { setBannerBlobUrl(undefined); return; } const blob = await response.blob(); blobUrl = URL.createObjectURL(blob); setBannerBlobUrl(blobUrl); }; loadBanner(); // Cleanup blob URL when component unmounts or deps change return () => { if (blobUrl) { URL.revokeObjectURL(blobUrl); } }; }, [mx, useAuthentication, avatarMxc, userId]); return bannerBlobUrl; }