diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index a97decd..8ee7e61 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -128,6 +128,10 @@ function PinnedMessage({ }, [room, eventId, mx]) ); + const sender = pinnedEvent?.getSender(); + const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined; + const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc); + const handleOpenClick: MouseEventHandler = (evt) => { evt.stopPropagation(); const evtId = evt.currentTarget.getAttribute('data-event-id'); @@ -175,14 +179,9 @@ function PinnedMessage({ ); - const sender = pinnedEvent.getSender()!; - const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; - const senderAvatarMxc = getMemberAvatarMxc(room, sender); + const displayName = getMemberDisplayName(room, sender!) ?? getMxIdLocalPart(sender!) ?? sender!; const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; - // Get custom user color from avatar metadata - const customUserColor = useOtherUserColor(sender, senderAvatarMxc); - const memberPowerTag = getMemberPowerTag(sender); const tagColor = memberPowerTag?.color ? accessibleTagColors?.get(memberPowerTag.color) diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index 6ee0f5c..1da70e8 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -204,9 +204,52 @@ export function useUserColor(): [ 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 +/** + * 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 diff --git a/src/app/utils/imageMetadata.ts b/src/app/utils/imageMetadata.ts index d1d694d..1823291 100644 --- a/src/app/utils/imageMetadata.ts +++ b/src/app/utils/imageMetadata.ts @@ -18,6 +18,46 @@ import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './w import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata'; +/** + * 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) { + this.cache.delete(key); + this.cache.set(key, value); + } + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key); + } + 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(); + } +} + /** * Gradient configuration for profile styling * All colors support RGBA format (e.g., #FF550080 for 50% opacity) @@ -300,6 +340,10 @@ export function getExtension(format: ImageFormat): string { } } +// LRU cache for avatar metadata to avoid refetching +const avatarMetadataCache = new LRUCache(100); +const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes + /** * Fetch and extract color from an image URL (supports all formats) * @param url - HTTP URL to fetch the image from @@ -307,6 +351,12 @@ export function getExtension(format: ImageFormat): string { * @returns The color string if found, or undefined */ export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise { + // Check if full metadata is cached + const cached = avatarMetadataCache.get(url); + if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { + return cached.metadata.color; + } + try { const response = await fetch(url, { method: 'GET', @@ -315,7 +365,15 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n if (!response.ok) return undefined; const data = await response.arrayBuffer(); - return extractColorFromImage(data); + const color = extractColorFromImage(data); + + // Cache the color result as part of metadata + avatarMetadataCache.set(url, { + metadata: { color }, + timestamp: Date.now() + }); + + return color; } catch { return undefined; } @@ -328,6 +386,12 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n * @returns Object with color and banner if found */ export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise { + // Check cache first + const cached = avatarMetadataCache.get(url); + if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { + return cached.metadata; + } + try { const response = await fetch(url, { method: 'GET', @@ -336,7 +400,15 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string if (!response.ok) return {}; const data = await response.arrayBuffer(); - return extractMetadataFromImage(data); + const metadata = extractMetadataFromImage(data); + + // Cache the result + avatarMetadataCache.set(url, { + metadata, + timestamp: Date.now() + }); + + return metadata; } catch { return {}; }