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:
2026-03-12 20:50:00 +11:00
parent fc30d81f8f
commit 834de012b4
13 changed files with 911 additions and 90 deletions

View File

@@ -29,7 +29,7 @@ import {
color,
toRem,
} from 'folds';
import { HexColorPicker } from 'react-colorful';
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@@ -48,8 +48,10 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { AngleSelector } from '../../../components/AngleSelector';
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
@@ -146,6 +148,134 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSavingColor(false);
};
// Profile style settings (border color and gradient)
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
// Initialize with transparent alpha so saved values show in preview until user edits
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
const [savingStyle, setSavingStyle] = useState(false);
const [styleError, setStyleError] = useState<string>();
// Track if user has started editing (to show local values in preview)
const [editingBorder, setEditingBorder] = useState(false);
const [editingGradient, setEditingGradient] = useState(false);
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
const rgbaToHex = (rgba: RgbaColor): string => {
const r = rgba.r.toString(16).padStart(2, '0');
const g = rgba.g.toString(16).padStart(2, '0');
const b = rgba.b.toString(16).padStart(2, '0');
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
return `#${r}${g}${b}${a}`;
};
// Helper to convert hex with alpha to RGBA
const hexToRgba = (hex: string): RgbaColor => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
if (result) {
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
};
}
return { r: 59, g: 130, b: 246, a: 1 };
};
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
const parseDirectionDegrees = (direction: string): number => {
const degMatch = direction.match(/^(\d+)deg$/i);
if (degMatch) return parseInt(degMatch[1], 10);
// Fallback for legacy "to X" format
const keywordMap: Record<string, number> = {
'to top': 0,
'to top right': 45,
'to right': 90,
'to bottom right': 135,
'to bottom': 180,
'to bottom left': 225,
'to left': 270,
'to top left': 315,
};
return keywordMap[direction.toLowerCase()] ?? 180;
};
// Sync local style state with loaded profile style
useEffect(() => {
if (profileStyle.avatarBorderColor) {
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
}
if (profileStyle.gradient) {
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
}
}, [profileStyle]);
const handleBorderColorSave = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
await syncUserAvatar();
setEditingBorder(false);
} catch (e) {
setStyleError('Failed to save border color');
}
setSavingStyle(false);
};
const handleBorderColorRemove = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ avatarBorderColor: undefined });
await syncUserAvatar();
setEditingBorder(false);
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
} catch (e) {
setStyleError('Failed to remove border color');
}
setSavingStyle(false);
};
const handleGradientSave = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({
gradient: {
direction: `${localGradientDirection}deg`,
startColor: rgbaToHex(localGradientStart),
stopColor: rgbaToHex(localGradientStop),
},
});
await syncUserAvatar();
setEditingGradient(false);
} catch (e) {
setStyleError('Failed to save gradient');
}
setSavingStyle(false);
};
const handleGradientRemove = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ gradient: undefined });
await syncUserAvatar();
setEditingGradient(false);
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
setLocalGradientDirection(180);
} catch (e) {
setStyleError('Failed to remove gradient');
}
setSavingStyle(false);
};
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(profile.displayName || '');
const [savingName, setSavingName] = useState(false);
@@ -419,6 +549,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSaving(false);
};
// Build gradient CSS for preview (shows local editing values when user is editing,
// otherwise falls back to saved profile style)
const previewGradient = editingGradient
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
: profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
// Build border color for preview (shows local editing value when user is editing,
// otherwise falls back to saved profile style)
const previewBorderColor = editingBorder
? rgbaToHex(localBorderColor)
: profileStyle.avatarBorderColor;
return (
<Box direction="Column" gap="300">
<Box
@@ -641,6 +785,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
paddingBottom: config.space.S400,
background: previewGradient,
}}
>
{/* Avatar */}
@@ -661,7 +806,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
as="button"
onClick={handleAvatarClick}
style={{
backgroundColor: color.Surface.Container,
background: previewGradient || color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
@@ -678,7 +823,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Avatar
size="500"
style={{
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
outline: previewBorderColor
? `${toRem(4)} solid ${previewBorderColor}`
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}}
>
<UserAvatar
@@ -885,6 +1032,126 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
</Box>
</Box>
</Box>
{/* Profile Style Settings */}
<Box
direction="Column"
gap="200"
style={{
padding: config.space.S300,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
}}
>
<Text size="H6">Profile Style</Text>
{/* Avatar Border Color */}
<Box direction="Column" gap="100">
<Text size="T300">Avatar Border Color</Text>
<Box gap="200" alignItems="Center" wrap="Wrap">
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
<Box direction="Column" gap="100">
<Text size="T200" style={{ opacity: 0.7 }}>
Preview: {rgbaToHex(localBorderColor)}
</Text>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: '50%',
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
backgroundColor: color.Surface.Container,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleBorderColorSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.avatarBorderColor && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleBorderColorRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
</Box>
</Box>
{/* Profile Gradient */}
<Box direction="Column" gap="100">
<Text size="T300">Profile Card Gradient</Text>
<Box gap="200" alignItems="Start" wrap="Wrap">
<Box direction="Column" gap="100">
<Text size="T200">Start Color</Text>
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">End Color</Text>
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">Direction</Text>
<AngleSelector
value={localGradientDirection}
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
/>
<Box
style={{
width: toRem(100),
height: toRem(60),
borderRadius: toRem(8),
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleGradientSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.gradient && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleGradientRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
</Box>
</Box>
{styleError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
)}
</Box>
{uploadAtom && (
<Box gap="200" direction="Column" style={{ width: '100%' }}>
<CompactUploadCardRenderer