- Updated useUserColor hook to support multiple image formats (PNG, WebP, JPEG, GIF) for color embedding and extraction. - Introduced unified image metadata utilities in imageMetadata.ts for detecting formats and handling color operations. - Added GIF metadata utilities in gifMetadata.ts for reading and writing user color. - Implemented JPEG metadata utilities in jpegMetadata.ts for embedding and extracting user color. - Created WebP metadata utilities in webpMetadata.ts for handling user color in WebP images. - Enhanced error handling and logging for unsupported formats and operations.
221 lines
6.6 KiB
TypeScript
221 lines
6.6 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { MatrixClient } from 'matrix-js-sdk';
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
import { useMediaAuthentication } from './useMediaAuthentication';
|
|
import { mxcUrlToHttp } from '../utils/matrix';
|
|
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 {
|
|
const response = await fetch(url);
|
|
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;
|
|
}
|
|
|
|
const extractedColor = await fetchAndExtractColor(httpUrl);
|
|
setColor(extractedColor);
|
|
} catch (e) {
|
|
console.error('Failed to load user color from avatar:', e);
|
|
setColor(undefined);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
loadColor();
|
|
}, [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);
|
|
|
|
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];
|
|
}
|
|
|
|
// Cache for user colors to avoid refetching for each message
|
|
const userColorCache = new Map<string, { color: string | undefined; timestamp: number }>();
|
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 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();
|
|
const [color, setColor] = useState<string | undefined>();
|
|
|
|
useEffect(() => {
|
|
if (!avatarMxc) {
|
|
setColor(undefined);
|
|
return;
|
|
}
|
|
|
|
// Check cache first
|
|
const cacheKey = `${userId}:${avatarMxc}`;
|
|
const cached = userColorCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
setColor(cached.color);
|
|
return;
|
|
}
|
|
|
|
const loadColor = async () => {
|
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
if (!httpUrl) {
|
|
setColor(undefined);
|
|
return;
|
|
}
|
|
|
|
const extractedColor = await fetchAndExtractColor(httpUrl);
|
|
|
|
// Cache the result
|
|
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
|
|
|
setColor(extractedColor);
|
|
};
|
|
|
|
loadColor();
|
|
}, [mx, useAuthentication, avatarMxc, userId]);
|
|
|
|
return color;
|
|
}
|
|
|