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>

View File

@@ -0,0 +1,180 @@
import React, { useState, useCallback, ChangeEventHandler } from 'react';
import {
Box,
Button,
config,
Dialog,
Input,
Text,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useUserPresence } from '../../hooks/useUserPresence';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
/**
* Props for the CustomStatusDialog component
*/
export interface CustomStatusDialogProps {
/** Whether the dialog is currently open */
open: boolean;
/** Callback function invoked when the dialog should close */
onClose: () => void;
}
/**
* CustomStatusDialog Component
*
* Provides a dialog interface for users to set or clear their custom status message.
* The status is stored in Matrix presence and will be visible to other users.
*
* @param props - Component props
* @returns The custom status dialog component
*/
export function CustomStatusDialog({ open, onClose }: CustomStatusDialogProps) {
const mx = useMatrixClient();
const userId = mx.getUserId();
const presence = useUserPresence(userId ?? '');
const [statusText, setStatusText] = useState(presence?.status || '');
/**
* Handles input changes for the status text field
* @param evt - The change event from the input
*/
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
setStatusText(evt.currentTarget.value);
};
/**
* Async callback to save the status to Matrix presence
*/
const [saveState, saveStatus] = useAsyncCallback(
useCallback(async () => {
await mx.setPresence({
presence: presence?.presence || 'online',
status_msg: statusText,
});
onClose();
}, [mx, presence?.presence, statusText, onClose])
);
/**
* Async callback to clear the status from Matrix presence
*/
const [clearState, clearStatus] = useAsyncCallback(
useCallback(async () => {
await mx.setPresence({
presence: presence?.presence || 'online',
status_msg: '',
});
setStatusText('');
onClose();
}, [mx, presence?.presence, onClose])
);
/**
* Handles form submission to save the custom status
* @param evt - The form submit event
*/
const handleSubmit = (evt: React.FormEvent) => {
evt.preventDefault();
saveStatus();
};
/**
* Handles the clear status button click
*/
const handleClear = () => {
clearStatus();
};
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
}}
>
<Dialog>
<Box
as="form"
onSubmit={handleSubmit}
direction="Column"
gap="400"
style={{ padding: config.space.S400 }}
>
<Box direction="Column" gap="100">
<Text size="H4">Set Custom Status</Text>
<Text size="T300" priority="400">
Your custom status will be visible to other users
</Text>
</Box>
<Box direction="Column" gap="200">
<Input
value={statusText}
onChange={handleChange}
placeholder="What's on your mind?"
maxLength={100}
autoFocus
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
/>
{statusText.length > 0 && (
<Text size="T200" priority="300">
{statusText.length}/100 characters
</Text>
)}
</Box>
{(saveState.status === AsyncStatus.Error || clearState.status === AsyncStatus.Error) && (
<Text size="T300" priority="400" style={{ color: 'var(--color-critical-main)' }}>
Failed to update status. Please try again.
</Text>
)}
<Box direction="Row" gap="200" justifyContent="End">
<Button
type="button"
variant="Secondary"
onClick={onClose}
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
>
<Text size="B400">Cancel</Text>
</Button>
{presence?.status && (
<Button
type="button"
variant="Critical"
fill="None"
onClick={handleClear}
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
>
<Text size="B400">Clear Status</Text>
</Button>
)}
<Button
type="submit"
variant="Primary"
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
>
<Text size="B400">
{saveState.status === AsyncStatus.Loading ? 'Saving...' : 'Save'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -0,0 +1 @@
export { CustomStatusDialog, type CustomStatusDialogProps } from './CustomStatusDialog';

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import {
Avatar,
Box,
color,
Icon,
Icons,
Modal,
@@ -9,38 +10,51 @@ import {
OverlayBackdrop,
OverlayCenter,
Text,
toRem,
} from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import * as css from './styles.css';
import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { useOtherUserBanner } from '../../hooks/useUserBanner';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
type UserHeroProps = {
userId: string;
avatarUrl?: string;
avatarMxc?: string;
presence?: UserPresence;
};
export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewAvatar, setViewAvatar] = useState<string>();
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
return (
<Box direction="Column" className={css.UserHero}>
<div
className={css.UserHeroCoverContainer}
style={{
backgroundColor: colorMXID(userId),
filter: avatarUrl ? undefined : 'brightness(50%)',
backgroundColor: bannerUrl ? undefined : colorMXID(userId),
filter: bannerUrl || avatarUrl ? undefined : 'brightness(50%)',
}}
>
{avatarUrl && (
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
{bannerUrl ? (
<img className={css.UserHeroBanner} src={bannerUrl} alt={userId} draggable="false" />
) : (
avatarUrl && (
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
)
)}
</div>
<div className={css.UserHeroAvatarContainer}>
@@ -65,6 +79,23 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
/>
</Avatar>
</AvatarPresence>
{presence?.status && (
<Box
className={css.UserStatusBubble}
style={{
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
}}
>
<Text size="T300" className={BreakWord}>
{presence.status}
</Text>
</Box>
)}
{viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -76,7 +107,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
<ImageViewer
src={viewAvatar}
alt={userId}
@@ -95,12 +126,14 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
type UserHeroNameProps = {
displayName?: string;
userId: string;
avatarMxc?: string;
};
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
const userColor = useOtherUserColor(userId, avatarMxc);
return (
<Box grow="Yes" direction="Column" gap="0">
<Box grow="Yes" direction="Column" gap="100">
<Box alignItems="Baseline" gap="200" wrap="Wrap">
<Text
size="H4"
@@ -111,8 +144,13 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
</Text>
</Box>
<Box alignItems="Center" gap="100" wrap="Wrap">
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username}
<Text
size="T200"
className={BreakWord}
title={userId}
style={{ color: userColor || colorMXID(userId) }}
>
{userId}
</Text>
</Box>
</Box>

View File

@@ -72,12 +72,17 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<UserHero
userId={userId}
avatarUrl={avatarUrl}
avatarMxc={avatarMxc}
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
/>
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<Box gap="400" alignItems="Start">
<UserHeroName displayName={displayName} userId={userId} />
<UserHeroName
displayName={displayName}
userId={userId}
avatarMxc={avatarMxc}
/>
{userId !== myUserId && (
<Box shrink="No">
<Button

View File

@@ -26,6 +26,12 @@ export const UserHeroCover = style({
transform: 'scale(2)',
});
export const UserHeroBanner = style({
height: '100%',
width: '100%',
objectFit: 'cover',
});
export const UserHeroAvatarContainer = style({
position: 'relative',
height: toRem(29),
@@ -37,6 +43,14 @@ export const UserAvatarContainer = style({
transform: 'translateY(-50%)',
backgroundColor: color.Surface.Container,
});
export const UserStatusBubble = style({
position: 'absolute',
left: `calc(${config.space.S400} + ${toRem(64)} + ${config.space.S200})`,
bottom: toRem(-8),
zIndex: 2,
});
export const UserHeroAvatar = style({
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
selectors: {