- Updated `useUserColor` hook to support fetching and embedding user color from avatar metadata with authentication. - Introduced `useUserBanner` hook for managing user banners stored in avatar metadata, including fetching and embedding functionality. - Added `fetchAndExtractMetadata` utility to retrieve both color and banner from images. - Implemented `CustomStatusDialog` component for users to set and clear custom status messages. - Modified `SettingsTab` to include custom status functionality and improved user experience. - Enhanced image metadata utilities to support banner extraction and embedding in PNG format. - Updated sidebar settings to handle user profile changes more gracefully.
296 lines
9.2 KiB
TypeScript
296 lines
9.2 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 {
|
|
fetchAndExtractMetadata,
|
|
extractMetadataFromImage,
|
|
embedMetadataInImage,
|
|
detectImageFormat,
|
|
getMimeType,
|
|
getExtension,
|
|
ImageMetadata,
|
|
} 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 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook to manage the user's chosen profile banner, stored in avatar image metadata
|
|
* The banner URL is embedded in avatar metadata and syncs via the avatar
|
|
* @returns The current banner URL, a setter function, and loading state
|
|
*/
|
|
export function useUserBanner(): [
|
|
string | undefined,
|
|
(banner: string | undefined) => Promise<void>,
|
|
boolean
|
|
] {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [banner, setBanner] = useState<string | undefined>();
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Extract banner from current avatar on mount and when profile changes
|
|
useEffect(() => {
|
|
const loadBanner = 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) {
|
|
setBanner(undefined);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
if (!httpUrl) {
|
|
setBanner(undefined);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const accessToken = mx.getAccessToken();
|
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
setBanner(metadata.banner);
|
|
} catch {
|
|
setBanner(undefined);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
loadBanner();
|
|
|
|
// Listen for avatar changes and reload banner
|
|
const userId = mx.getUserId();
|
|
if (!userId) return undefined;
|
|
|
|
const user = mx.getUser(userId);
|
|
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
|
loadBanner();
|
|
};
|
|
|
|
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
|
|
return () => {
|
|
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
|
};
|
|
}, [mx, useAuthentication]);
|
|
|
|
const updateBanner = useCallback(async (newBanner: string | undefined) => {
|
|
const userId = mx.getUserId();
|
|
if (!userId) {
|
|
throw new Error('No user ID');
|
|
}
|
|
|
|
console.log('[useUserBanner] Starting banner update:', newBanner);
|
|
|
|
const profile = await mx.getProfileInfo(userId);
|
|
const avatarUrl = profile.avatar_url;
|
|
|
|
if (!avatarUrl) {
|
|
throw new Error('No avatar set. Please upload an avatar first.');
|
|
}
|
|
|
|
console.log('[useUserBanner] Current avatar URL:', avatarUrl);
|
|
|
|
// Fetch current avatar
|
|
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
|
if (!avatarData) {
|
|
throw new Error('Failed to fetch current avatar');
|
|
}
|
|
|
|
console.log('[useUserBanner] Fetched avatar data, size:', avatarData.byteLength);
|
|
|
|
// Detect image format
|
|
const format = detectImageFormat(avatarData);
|
|
console.log('[useUserBanner] Detected format:', format);
|
|
|
|
if (format === 'unknown') {
|
|
throw new Error('Unsupported avatar image format');
|
|
}
|
|
|
|
// Get existing metadata to preserve
|
|
const existingMetadata = extractMetadataFromImage(avatarData);
|
|
console.log('[useUserBanner] Existing metadata:', existingMetadata);
|
|
|
|
// Modify image metadata with new banner, preserving color
|
|
const newMetadata: ImageMetadata = {
|
|
color: existingMetadata.color,
|
|
banner: newBanner,
|
|
};
|
|
|
|
console.log('[useUserBanner] Embedding metadata:', newMetadata);
|
|
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
|
|
|
if (!newAvatarData) {
|
|
throw new Error('Failed to embed banner in avatar metadata');
|
|
}
|
|
|
|
console.log('[useUserBanner] New avatar data size:', newAvatarData.byteLength);
|
|
|
|
// Verify the banner was embedded correctly before uploading
|
|
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
|
console.log('[useUserBanner] Verification metadata:', verifyMetadata);
|
|
|
|
if (newBanner && verifyMetadata.banner !== newBanner) {
|
|
throw new Error('Banner verification failed');
|
|
}
|
|
|
|
// Upload modified avatar with correct MIME type
|
|
const mimeType = getMimeType(format);
|
|
const extension = getExtension(format);
|
|
const blob = new Blob([newAvatarData], { type: mimeType });
|
|
|
|
console.log('[useUserBanner] Uploading avatar blob:', blob.size, 'bytes, type:', mimeType);
|
|
const uploadResponse = await mx.uploadContent(blob, {
|
|
name: `avatar.${extension}`,
|
|
type: mimeType,
|
|
});
|
|
|
|
console.log('[useUserBanner] Upload response:', uploadResponse.content_uri);
|
|
|
|
// Update profile with new avatar
|
|
console.log('[useUserBanner] Calling setAvatarUrl...');
|
|
try {
|
|
await Promise.race([
|
|
mx.setAvatarUrl(uploadResponse.content_uri),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000))
|
|
]);
|
|
console.log('[useUserBanner] Avatar URL updated successfully');
|
|
|
|
// 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);
|
|
}
|
|
} catch (error) {
|
|
console.error('[useUserBanner] setAvatarUrl failed:', error);
|
|
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
|
|
// Note: Don't set banner here - let the avatar change event handle it
|
|
// setBanner(newBanner);
|
|
}, [mx, useAuthentication]);
|
|
|
|
return [banner, updateBanner, loading];
|
|
}
|
|
|
|
// Cache for user banners to avoid refetching for each message
|
|
const userBannerCache = new Map<string, { banner: string | undefined; timestamp: number }>();
|
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
/**
|
|
* Hook to get another user's chosen profile banner from their avatar metadata
|
|
* @param userId - The user ID to get banner for
|
|
* @param avatarMxc - The user's avatar MXC URL
|
|
* @returns The user's chosen banner as a blob URL or undefined
|
|
*/
|
|
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
|
|
|
|
useEffect(() => {
|
|
let blobUrl: string | undefined;
|
|
|
|
if (!avatarMxc) {
|
|
setBannerBlobUrl(undefined);
|
|
return;
|
|
}
|
|
|
|
// Check cache first
|
|
const cacheKey = `${userId}:${avatarMxc}`;
|
|
const cached = userBannerCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
setBannerBlobUrl(cached.banner);
|
|
return;
|
|
}
|
|
|
|
const loadBanner = async () => {
|
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
if (!httpUrl) {
|
|
setBannerBlobUrl(undefined);
|
|
return;
|
|
}
|
|
|
|
const accessToken = mx.getAccessToken();
|
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
|
|
if (!metadata.banner) {
|
|
// Cache the result
|
|
userBannerCache.set(cacheKey, { banner: undefined, timestamp: Date.now() });
|
|
setBannerBlobUrl(undefined);
|
|
return;
|
|
}
|
|
|
|
// Fetch the banner image data with authentication
|
|
const bannerHttpUrl = mxcUrlToHttp(mx, metadata.banner, useAuthentication);
|
|
if (!bannerHttpUrl) {
|
|
setBannerBlobUrl(undefined);
|
|
return;
|
|
}
|
|
|
|
const headers: HeadersInit = {};
|
|
if (useAuthentication && accessToken) {
|
|
headers.Authorization = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const response = await fetch(bannerHttpUrl, { headers });
|
|
if (!response.ok) {
|
|
console.warn('Failed to fetch banner image:', response.status);
|
|
setBannerBlobUrl(undefined);
|
|
return;
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
blobUrl = URL.createObjectURL(blob);
|
|
|
|
// Cache the blob URL
|
|
userBannerCache.set(cacheKey, { banner: blobUrl, timestamp: Date.now() });
|
|
|
|
setBannerBlobUrl(blobUrl);
|
|
};
|
|
|
|
loadBanner();
|
|
|
|
// Cleanup blob URL when component unmounts or deps change
|
|
return () => {
|
|
if (blobUrl) {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
};
|
|
}, [mx, useAuthentication, avatarMxc, userId]);
|
|
|
|
return bannerBlobUrl;
|
|
}
|