316 lines
10 KiB
TypeScript
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;
|
|
}
|
|
|