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.
This commit is contained in:
245
src/app/hooks/useUserProfileStyle.ts
Normal file
245
src/app/hooks/useUserProfileStyle.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user