feat: Enhance user color and banner management

- 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.
This commit is contained in:
2026-03-12 10:58:38 +11:00
parent 2c2560f5a2
commit fc30d81f8f
12 changed files with 1804 additions and 419 deletions

View File

@@ -1,15 +1,61 @@
import React from 'react';
import { Box, Text, Icon, Icons, MenuItem, config } from 'folds';
import React, { useEffect, useState } from 'react';
import { Avatar, Box, Icon, Icons, MenuItem, Text, config } from 'folds';
import { useAtomValue } from 'jotai';
import { sessionsAtom, Session } from '../../state/sessions';
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';
import { getMxIdServer } from '../../utils/matrix';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../user-avatar';
type AccountSwitcherProps = {
currentUserId?: string;
};
/**
* Loads and displays an avatar for a session that is not the active client.
* Fetches the profile directly via the homeserver REST API using stored credentials.
*/
function OtherSessionAvatar({ session }: { session: Session }) {
const [src, setSrc] = useState<string | undefined>();
useEffect(() => {
const load = async () => {
try {
const res = await fetch(
`${session.baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(session.userId)}`,
{ headers: { Authorization: `Bearer ${session.accessToken}` } }
);
if (!res.ok) return;
const data = await res.json();
if (data.avatar_url) {
const match = (data.avatar_url as string).match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (match) {
setSrc(
`${session.baseUrl}/_matrix/media/v3/thumbnail/${match[1]}/${match[2]}?width=32&height=32&method=crop`
);
}
}
} catch {
// ignore — avatar just won't show
}
};
load();
}, [session.userId, session.baseUrl, session.accessToken]);
return (
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
<UserAvatar
userId={session.userId}
src={src}
alt={session.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
);
}
const deleteAllMatrixDatabases = async (): Promise<void> => {
console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...');
@@ -79,6 +125,11 @@ const deleteAllMatrixDatabases = async (): Promise<void> => {
export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
const sessions = useAtomValue(sessionsAtom);
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const currentProfile = useUserProfile(currentUserId ?? '');
const currentAvatarUrl = currentProfile.avatarUrl
? mxcUrlToHttp(mx, currentProfile.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
const handleSwitchAccount = async (session: Session) => {
if (session.userId === currentUserId) return;
@@ -120,8 +171,8 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
const hasMultipleAccounts = sessions.length > 1;
return (
<Box direction="Column" gap="100">
<Text size="L400" style={{ padding: config.space.S200 }}>
<Box direction="Column" gap="200">
<Text size="L400" style={{ paddingInline: config.space.S200, paddingBlock: config.space.S100 }}>
{hasMultipleAccounts ? 'Switch Account' : 'Accounts'}
</Text>
@@ -133,11 +184,19 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
radii="300"
variant="SurfaceVariant"
disabled
style={{ paddingInline: config.space.S200 }}
after={<Icon size="100" src={Icons.Check} filled />}
>
<Box gap="200" alignItems="Center">
<Icon size="100" src={Icons.User} />
<Box direction="Column" gap="100">
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
<UserAvatar
userId={currentUserId}
src={currentAvatarUrl}
alt={currentUserId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
<Box direction="Column" style={{ gap: '2px' }}>
<Text size="T300" truncate>
{currentUserId}
</Text>
@@ -162,10 +221,11 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
radii="300"
onClick={() => handleSwitchAccount(session)}
variant="Surface"
style={{ paddingInline: config.space.S200 }}
>
<Box gap="200" alignItems="Center">
<Icon size="100" src={Icons.User} />
<Box direction="Column" gap="100">
<OtherSessionAvatar session={session} />
<Box direction="Column" style={{ gap: '2px' }}>
<Text size="T300" truncate>
{session.userId}
</Text>