Files
cinny/src/app/hooks/useUserProfileStyle.ts
Max Litruv Boonzaayer 834de012b4 feat: enhance user profile customization with avatar border color and gradient options
- Added support for setting avatar border color and gradient in user profile settings.
- Introduced new hooks for managing user profile styles and fetching metadata from avatars.
- Implemented an AngleSelector component for selecting gradient direction.
- Updated RoomNavItem to display parent space information for direct messages.
- Improved caching mechanism for user banners and profile styles.
- Refactored image metadata handling to include new profile style attributes.
2026-03-12 20:50:00 +11:00

246 lines
7.1 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import {
fetchAndExtractMetadata,
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
ProfileGradient,
} from '../utils/imageMetadata';
/**
* Fetches the user's current avatar as raw image data
*/
async function fetchAvatarData(
mx: ReturnType<typeof useMatrixClient>,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!url) return null;
try {
const accessToken = mx.getAccessToken();
const response = await fetch(url, {
method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
export type ProfileStyleData = {
avatarBorderColor?: string;
gradient?: ProfileGradient;
};
/**
* Hook to manage the user's profile style (avatar border color and gradient)
* Stored in avatar image metadata
* @returns Current style data, a setter function, and loading state
*/
export function useUserProfileStyle(): [
ProfileStyleData,
(style: Partial<ProfileStyleData>) => Promise<void>,
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [style, setStyle] = useState<ProfileStyleData>({});
const [loading, setLoading] = useState(true);
// Extract style from current avatar on mount and when profile changes
useEffect(() => {
const loadStyle = 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) {
setStyle({});
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setStyle({});
setLoading(false);
return;
}
const accessToken = mx.getAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
setStyle({
avatarBorderColor: metadata.avatarBorderColor,
gradient: metadata.gradient,
});
} catch {
setStyle({});
}
setLoading(false);
};
loadStyle();
// Listen for avatar changes and reload style
const userId = mx.getUserId();
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
loadStyle();
};
user?.on(UserEvent.AvatarUrl, onAvatarChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
};
}, [mx, useAuthentication]);
const updateStyle = useCallback(
async (newStyle: Partial<ProfileStyleData>) => {
const userId = mx.getUserId();
if (!userId) {
throw new Error('No user ID');
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
throw new Error('No avatar set. Please upload an avatar first.');
}
// Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) {
throw new Error('Failed to fetch current avatar');
}
// Detect image format
const format = detectImageFormat(avatarData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
// Merge with new style
const newMetadata: ImageMetadata = {
...existingMetadata,
avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor,
gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient,
};
// Embed updated metadata
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
if (!newAvatarData) {
throw new Error('Failed to embed style in avatar metadata');
}
// 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,
});
// Update profile with new avatar
await mx.setAvatarUrl(uploadResponse.content_uri);
// Manually sync user object
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
}
// Update local state
setStyle((prev) => ({
...prev,
...newStyle,
}));
},
[mx, useAuthentication]
);
return [style, updateStyle, loading];
}
// Cache for other users' profile styles
const userStyleCache = new Map<string, { style: ProfileStyleData; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Hook to get another user's profile style from their avatar metadata
* @param userId - The user ID to get style for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's profile style data
*/
export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [style, setStyle] = useState<ProfileStyleData>({});
useEffect(() => {
if (!avatarMxc) {
setStyle({});
return;
}
const loadStyle = async () => {
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userStyleCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
setStyle(cached.style);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!httpUrl) {
setStyle({});
return;
}
const accessToken = mx.getAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
const styleData: ProfileStyleData = {
avatarBorderColor: metadata.avatarBorderColor,
gradient: metadata.gradient,
};
// Cache the result
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
setStyle(styleData);
};
loadStyle();
}, [mx, useAuthentication, avatarMxc, userId]);
return style;
}