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

@@ -1,5 +1,6 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Room, EventTimeline } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import {
Avatar,
Box,
@@ -16,13 +17,15 @@ import {
RectCords,
Badge,
Spinner,
Tooltip,
TooltipProvider,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent, getOrphanParents } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -36,7 +39,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
@@ -54,6 +57,9 @@ import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
import { useCall } from '../call/useCall';
import { CallType } from '../call/types';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList';
type RoomNavItemMenuProps = {
room: Room;
@@ -273,7 +279,31 @@ export function RoomNavItem({
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Get parent space for DMs
const parentSpaceInfo = (() => {
if (!direct || !mDirects.has(room.roomId)) return undefined;
const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length === 0) return undefined;
const parentSpaceId = guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
const parentSpace = mx.getRoom(parentSpaceId);
if (!parentSpace) return undefined;
const avatarEvent = parentSpace.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(StateEvent.RoomAvatar, '');
const avatarContent = avatarEvent?.getContent();
const avatarMxc = avatarContent && typeof avatarContent.url === 'string' ? avatarContent.url : undefined;
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
return {
name: parentSpace.name,
avatarUrl,
roomId: parentSpace.roomId,
};
})();
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
setMenuAnchor({
@@ -353,43 +383,72 @@ export function RoomNavItem({
</NavLink>
{optionsVisible && (
<NavItemOptions>
<PopOut
anchor={menuAnchor}
offset={menuAnchor?.width === 0 ? 0 : undefined}
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
<Box alignItems="Center" gap="100">
{parentSpaceInfo && (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">Space: {parentSpaceInfo.name}</Text>
</Tooltip>
}
>
<RoomNavItemMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
{(triggerRef) => (
<Avatar ref={triggerRef} size="200" radii="Pill" style={{ flexShrink: 0, width: '16px', height: '16px' }}>
<RoomAvatar
roomId={parentSpaceInfo.roomId}
src={parentSpaceInfo.avatarUrl}
alt={parentSpaceInfo.name}
renderFallback={() => (
<Text size="Inherit">
{nameInitials(parentSpaceInfo.name, 1)}
</Text>
)}
/>
</Avatar>
)}
</TooltipProvider>
)}
{optionsVisible && (
<PopOut
anchor={menuAnchor}
offset={menuAnchor?.width === 0 ? 0 : undefined}
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomNavItemMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
)}
</Box>
</NavItemOptions>
)}
</NavItem>