/** * Media caching utilities * Provides cached access to avatars and media with blob storage */ import { getCachedMedia, cacheMedia, getCachedAvatar, cacheAvatar, isCacheAvailable } from './index'; // In-memory cache for blob URLs to avoid creating duplicates const blobUrlCache = new Map(); /** * Fetch media with caching support * Returns a blob URL that can be used directly in img/video/audio elements */ export async function fetchMediaCached(url: string): Promise { // Check in-memory cache first const memCached = blobUrlCache.get(url); if (memCached) return memCached; // Check IndexedDB cache if (isCacheAvailable()) { const cachedBlob = await getCachedMedia(url); if (cachedBlob) { const blobUrl = URL.createObjectURL(cachedBlob); blobUrlCache.set(url, blobUrl); return blobUrl; } } // Fetch from network try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(); // Cache in IndexedDB if (isCacheAvailable()) { cacheMedia(url, blob).catch(() => { }); } // Create and cache blob URL const blobUrl = URL.createObjectURL(blob); blobUrlCache.set(url, blobUrl); return blobUrl; } catch { // Return original URL as fallback return url; } } /** * Fetch avatar with caching support * Handles mxc:// URLs with authenticated requests */ export async function fetchAvatarCached( mxcUrl: string | null, homeserverUrl: string, size = 40 ): Promise { if (!mxcUrl) return null; // Check in-memory cache first (fastest) const memCached = blobUrlCache.get(mxcUrl); if (memCached) return memCached; // Check IndexedDB cache if (isCacheAvailable()) { const cached = await getCachedAvatar(mxcUrl); if (cached) { blobUrlCache.set(mxcUrl, cached.blobUrl); return cached.blobUrl; } } // Get auth token for authenticated fetch let accessToken: string | null = null; try { const creds = localStorage.getItem('matrix_credentials'); if (creds) { accessToken = JSON.parse(creds).accessToken; } } catch { } // Convert mxc:// to authenticated HTTP URL const httpUrl = mxcToHttpAuth(mxcUrl, homeserverUrl, size); if (!httpUrl) return null; // Fetch from network with auth try { const headers: HeadersInit = {}; if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await fetch(httpUrl, { headers }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(); // Cache in IndexedDB if (isCacheAvailable()) { cacheAvatar(mxcUrl, httpUrl, blob).catch(() => { }); } // Create and cache blob URL const blobUrl = URL.createObjectURL(blob); blobUrlCache.set(mxcUrl, blobUrl); return blobUrl; } catch { return null; } } /** * Convert mxc:// URL to authenticated HTTP thumbnail URL * Uses /_matrix/client/v1/media/ which requires auth but is the modern standard */ function mxcToHttpAuth(mxcUrl: string, homeserverUrl: string, size: number): string | null { if (!mxcUrl.startsWith('mxc://')) return null; const parts = mxcUrl.slice(6).split('/'); if (parts.length !== 2) return null; const [serverName, mediaId] = parts; // Use authenticated thumbnail endpoint if (size <= 96) { return `${homeserverUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`; } return `${homeserverUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`; } /** * Convert mxc:// URL to HTTP thumbnail URL (legacy, unauthenticated) */ function mxcToHttp(mxcUrl: string, homeserverUrl: string, size: number): string | null { if (!mxcUrl.startsWith('mxc://')) return null; const parts = mxcUrl.slice(6).split('/'); if (parts.length !== 2) return null; const [serverName, mediaId] = parts; // Use thumbnail endpoint for smaller sizes if (size <= 96) { return `${homeserverUrl}/_matrix/media/v3/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`; } // Use download endpoint for larger sizes return `${homeserverUrl}/_matrix/media/v3/download/${serverName}/${mediaId}`; } /** * Preload avatars for a list of users * Call this when loading a room to cache avatars in advance */ export async function preloadAvatars( avatarUrls: (string | null)[], homeserverUrl: string ): Promise { const uniqueUrls = [...new Set(avatarUrls.filter(Boolean))] as string[]; // Preload in parallel, but limit concurrency const batchSize = 5; for (let i = 0; i < uniqueUrls.length; i += batchSize) { const batch = uniqueUrls.slice(i, i + batchSize); await Promise.all( batch.map(url => fetchAvatarCached(url, homeserverUrl).catch(() => null)) ); } } /** * Clear blob URL cache (call on logout) */ export function clearBlobUrlCache(): void { for (const blobUrl of blobUrlCache.values()) { URL.revokeObjectURL(blobUrl); } blobUrlCache.clear(); } /** * Get cache statistics */ export function getBlobCacheStats(): { count: number; urls: string[] } { return { count: blobUrlCache.size, urls: [...blobUrlCache.keys()], }; }