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

@@ -0,0 +1,189 @@
import React, { useRef, useCallback, useState } from 'react';
import { Box, Text, toRem, color } from 'folds';
/**
* Props for the AngleSelector component
*/
export interface AngleSelectorProps {
/** Current angle in degrees (0-360) */
value: number;
/** Callback when angle changes */
onChange: (angle: number) => void;
/** Size of the selector in pixels */
size?: number;
/** Whether the component is disabled */
disabled?: boolean;
}
/**
* A circular angle selector similar to Photoshop's drop shadow angle control.
* Click or drag around the circle to set an angle in degrees.
*/
export function AngleSelector({
value,
onChange,
size = 80,
disabled = false,
}: AngleSelectorProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
/**
* Calculate angle from mouse/touch position relative to center
*/
const calculateAngle = useCallback(
(clientX: number, clientY: number) => {
if (!containerRef.current) return value;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const deltaX = clientX - centerX;
const deltaY = clientY - centerY;
// Calculate angle in radians, then convert to degrees
// atan2 returns angle from -PI to PI, where 0 is pointing right
// We adjust so 0deg is pointing up (like CSS gradients)
let angle = Math.atan2(deltaX, -deltaY) * (180 / Math.PI);
// Normalize to 0-360 range
if (angle < 0) angle += 360;
return Math.round(angle);
},
[value]
);
/**
* Handle mouse/touch start
*/
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const newAngle = calculateAngle(e.clientX, e.clientY);
onChange(newAngle);
},
[disabled, calculateAngle, onChange]
);
/**
* Handle mouse/touch move
*/
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging || disabled) return;
const newAngle = calculateAngle(e.clientX, e.clientY);
onChange(newAngle);
},
[isDragging, disabled, calculateAngle, onChange]
);
/**
* Handle mouse/touch end
*/
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// Calculate line endpoint position
const lineLength = (size / 2) - 8;
const radians = ((value - 90) * Math.PI) / 180;
const lineX = Math.cos(radians) * lineLength;
const lineY = Math.sin(radians) * lineLength;
return (
<Box direction="Column" gap="100" alignItems="Center">
<Box
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
style={{
width: toRem(size),
height: toRem(size),
borderRadius: '50%',
border: `${toRem(2)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
position: 'relative',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
touchAction: 'none',
userSelect: 'none',
}}
>
{/* Center dot */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Secondary.Main,
}}
/>
{/* Direction line */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(2),
height: toRem(lineLength),
backgroundColor: color.Primary.Main,
transformOrigin: 'center top',
transform: `translate(-50%, 0) rotate(${value}deg)`,
borderRadius: toRem(1),
}}
/>
{/* Angle indicator dot at end of line */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(10),
height: toRem(10),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
transform: `translate(calc(-50% + ${lineX}px), calc(-50% + ${lineY}px))`,
boxShadow: `0 0 ${toRem(4)} ${color.Primary.Main}`,
}}
/>
{/* Cardinal direction markers */}
{[0, 90, 180, 270].map((deg) => {
const markerRadians = ((deg - 90) * Math.PI) / 180;
const markerDist = (size / 2) - 3;
const markerX = Math.cos(markerRadians) * markerDist;
const markerY = Math.sin(markerRadians) * markerDist;
return (
<Box
key={deg}
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(4),
height: toRem(4),
borderRadius: '50%',
backgroundColor: color.Surface.OnContainer,
opacity: 0.3,
transform: `translate(calc(-50% + ${markerX}px), calc(-50% + ${markerY}px))`,
}}
/>
);
})}
</Box>
<Text size="T200">{value}°</Text>
</Box>
);
}
export default AngleSelector;

View File

@@ -31,11 +31,12 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
// Silently ignore OIDC configuration errors when server doesn't support it
// This is expected for most Matrix servers
if (authMetadata) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
// Server returned auth metadata but it failed validation — ignore silently.
}
}
return {

View File

@@ -3,7 +3,7 @@ import { Text, as } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export const NavItemContent = as<'p', ComponentProps<typeof Text>>(
export const NavItemContent = as<'span', ComponentProps<typeof Text>>(
({ className, ...props }, ref) => (
<Text className={classNames(css.NavItemContent, className)} size="T300" {...props} ref={ref} />
)

View File

@@ -307,7 +307,6 @@ export function TitleBar({ mx, children }: TitleBarProps) {
// Ignore if it's the currently active room
if (room.roomId === roomId) {
console.log('[TitleBar] Skipping: active room');
return;
}

View File

@@ -17,7 +17,7 @@ import FocusTrap from 'focus-trap-react';
import * as css from './styles.css';
import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
@@ -25,8 +25,7 @@ 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';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
type UserHeroProps = {
userId: string;
@@ -35,13 +34,20 @@ type UserHeroProps = {
presence?: UserPresence;
};
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
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
// Build gradient CSS if configured
const gradientStyle = profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
return (
<Box direction="Column" className={css.UserHero}>
<Box
direction="Column"
className={css.UserHero}
>
<div
className={css.UserHeroCoverContainer}
style={{
@@ -57,9 +63,10 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
)
)}
</div>
<div className={css.UserHeroAvatarContainer}>
<div className={css.UserHeroAvatarContainer} style={gradientStyle ? { background: gradientStyle } : undefined}>
<AvatarPresence
className={css.UserAvatarContainer}
style={gradientStyle ? { background: gradientStyle } : undefined}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
@@ -69,6 +76,10 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
style={profileStyle.avatarBorderColor ? {
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
outlineOffset: toRem(-1),
} : undefined}
>
<UserAvatar
className={css.UserHeroAvatarImg}

View File

@@ -15,6 +15,7 @@ export const UserHero = style({
});
export const UserHeroCoverContainer = style({
position: 'relative',
height: toRem(96),
overflow: 'hidden',
});