Files
cinny/src/app/hooks/useUserColor.ts

316 lines
10 KiB
TypeScript

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 {
embedColorInImage,
extractColorFromImage,
removeColorFromImage,
fetchAndExtractColor,
detectImageFormat,
getMimeType,
getExtension,
} from '../utils/imageMetadata';
/**
* Fetches the user's current avatar as raw image data
*/
async function fetchAvatarData(
mx: MatrixClient,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
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) {
console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback');
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 color, stored in avatar image metadata
* The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar
* @returns The current color, a setter function, and loading state
*/
export function useUserColor(): [
string | undefined,
(color: string | undefined) => Promise<void>,
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [color, setColor] = useState<string | undefined>();
const [loading, setLoading] = useState(true);
// Extract color from current avatar on mount and when profile changes
useEffect(() => {
const loadColor = 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) {
setColor(undefined);
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setColor(undefined);
setLoading(false);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
setColor(extractedColor);
} catch (e) {
console.error('Failed to load user color from avatar:', e);
setColor(undefined);
}
setLoading(false);
};
loadColor();
// Listen for avatar changes and reload color
const userId = mx.getUserId();
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
loadColor();
};
user?.on(UserEvent.AvatarUrl, onAvatarChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
};
}, [mx, useAuthentication]);
const updateColor = useCallback(async (newColor: string | undefined) => {
const userId = mx.getUserId();
if (!userId) return;
try {
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
// No avatar to embed color in
console.warn('Cannot set color: no avatar image set');
throw new Error('No avatar set');
}
// Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) {
console.error('Failed to fetch current avatar');
throw new Error('Failed to fetch avatar');
}
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
// Detect image format
const format = detectImageFormat(avatarData);
console.log('Detected image format:', format);
if (format === 'unknown') {
console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF');
throw new Error('Unsupported image format');
}
// Modify image metadata
let newAvatarData: Uint8Array | null;
if (newColor) {
newAvatarData = embedColorInImage(avatarData, newColor);
} else {
newAvatarData = removeColorFromImage(avatarData);
}
if (!newAvatarData) {
console.error('Failed to modify avatar image metadata');
throw new Error('Failed to modify image metadata');
}
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
// Verify the color was embedded correctly before uploading
const verifyColor = extractColorFromImage(newAvatarData);
console.log('Verification - embedded color:', verifyColor);
if (newColor && verifyColor !== newColor) {
console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
throw new Error('Color embedding verification failed');
}
// 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,
});
console.log('Uploaded new avatar:', uploadResponse.content_uri);
// Update profile with new avatar
await mx.setAvatarUrl(uploadResponse.content_uri);
// Manually sync user object to ensure event listeners are triggered
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
}
setColor(newColor);
console.log('User color updated in avatar:', newColor);
} catch (e) {
console.error('Failed to update user color in avatar:', e);
throw e;
}
}, [mx, useAuthentication]);
return [color, updateColor, loading];
}
/**
* 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) {
// 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
* @param userId - The user ID to get color for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's chosen color or undefined
*/
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
// Check cache synchronously BEFORE initializing state to avoid flash
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
? cachedEntry.color
: undefined;
const [color, setColor] = useState<string | undefined>(cachedColor);
useEffect(() => {
if (!avatarMxc) {
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
setColor(undefined);
return;
}
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userColorCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
setColor(cached.color);
return;
}
const loadColor = async () => {
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
setColor(undefined);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
// Cache the result
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
setColor(extractedColor);
};
loadColor();
}, [mx, useAuthentication, avatarMxc, userId]);
return color;
}