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:
189
src/app/components/AngleSelector.tsx
Normal file
189
src/app/components/AngleSelector.tsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const UserHero = style({
|
||||
});
|
||||
|
||||
export const UserHeroCoverContainer = style({
|
||||
position: 'relative',
|
||||
height: toRem(96),
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user