feat: implement LRU caching for user colors and avatar metadata to optimize performance

This commit is contained in:
2026-04-22 02:47:21 +10:00
parent d4cb0b7435
commit ec6fe77567
3 changed files with 125 additions and 11 deletions

View File

@@ -128,6 +128,10 @@ function PinnedMessage({
}, [room, eventId, mx]) }, [room, eventId, mx])
); );
const sender = pinnedEvent?.getSender();
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
const handleOpenClick: MouseEventHandler = (evt) => { const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation(); evt.stopPropagation();
const evtId = evt.currentTarget.getAttribute('data-event-id'); const evtId = evt.currentTarget.getAttribute('data-event-id');
@@ -175,14 +179,9 @@ function PinnedMessage({
</Box> </Box>
); );
const sender = pinnedEvent.getSender()!; const displayName = getMemberDisplayName(room, sender!) ?? getMxIdLocalPart(sender!) ?? sender!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
// Get custom user color from avatar metadata
const customUserColor = useOtherUserColor(sender, senderAvatarMxc);
const memberPowerTag = getMemberPowerTag(sender); const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color) ? accessibleTagColors?.get(memberPowerTag.color)

View File

@@ -204,9 +204,52 @@ export function useUserColor(): [
return [color, updateColor, loading]; return [color, updateColor, loading];
} }
// Cache for user colors to avoid refetching for each message /**
const userColorCache = new Map<string, { color: string | undefined; timestamp: number }>(); * LRU Cache implementation with max size limit
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes */
class LRUCache<K, V> {
private cache: Map<K, V>;
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<string, { color: string | undefined; timestamp: number }>(100);
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
/** /**
* Hook to get another user's chosen profile color from their avatar metadata * Hook to get another user's chosen profile color from their avatar metadata

View File

@@ -18,6 +18,46 @@ import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './w
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata'; import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
/**
* LRU Cache implementation with max size limit
*/
class LRUCache<K, V> {
private cache: Map<K, V>;
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 * Gradient configuration for profile styling
* All colors support RGBA format (e.g., #FF550080 for 50% opacity) * 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<string, { metadata: ImageMetadata; timestamp: number }>(100);
const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
/** /**
* Fetch and extract color from an image URL (supports all formats) * Fetch and extract color from an image URL (supports all formats)
* @param url - HTTP URL to fetch the image from * @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 * @returns The color string if found, or undefined
*/ */
export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise<string | undefined> { export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise<string | undefined> {
// 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 { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
@@ -315,7 +365,15 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
if (!response.ok) return undefined; if (!response.ok) return undefined;
const data = await response.arrayBuffer(); 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 { } catch {
return undefined; return undefined;
} }
@@ -328,6 +386,12 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
* @returns Object with color and banner if found * @returns Object with color and banner if found
*/ */
export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise<ImageMetadata> { export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise<ImageMetadata> {
// Check cache first
const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
return cached.metadata;
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
@@ -336,7 +400,15 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
if (!response.ok) return {}; if (!response.ok) return {};
const data = await response.arrayBuffer(); 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 { } catch {
return {}; return {};
} }