}
@@ -90,23 +83,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
/>
- {presence?.status && (
-
-
- {presence.status}
-
-
- )}
{viewAvatar && (
}>
@@ -150,6 +126,7 @@ export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNamePro
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName ?? username}
+ style={{ color: userColor || colorMXID(userId) }}
>
{displayName ?? username ?? userId}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
index fcfe167..5727a1a 100644
--- a/src/app/components/user-profile/UserRoomProfile.tsx
+++ b/src/app/components/user-profile/UserRoomProfile.tsx
@@ -1,4 +1,4 @@
-import { Box, Button, config, Icon, Icons, Text } from 'folds';
+import { Box, Button, color, config, Icon, Icons, Text, toRem } from 'folds';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
@@ -9,6 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
+import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
@@ -22,6 +23,10 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
+import { getContrastingTextColor } from '../../utils/common';
+import classNames from 'classnames';
+import { BreakWord, LineClamp3 } from '../../styles/Text.css';
+import colorMXID from '../../../util/colorMXID';
type UserRoomProfileProps = {
userId: string;
@@ -58,6 +63,12 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const presence = useUserPresence(userId);
+ 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;
const handleMessage = () => {
closeUserRoomProfile();
@@ -67,6 +78,15 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
navigate(withSearchParam(getDirectCreatePath(), directSearchParam));
};
+ const hasBottomContent =
+ ignored ||
+ (!!member && membership === Membership.Ban) ||
+ (!!member && membership === Membership.Leave && !!member.events.member && member.events.member.getSender() !== userId) ||
+ (!!member && membership === Membership.Invite) ||
+ (canInvite && membership === Membership.Leave) ||
+ (canKickUser && membership === Membership.Join) ||
+ (canBanUser && membership !== Membership.Ban);
+
return (
-
-
-
-
- {userId !== myUserId && (
-
- }
- onClick={handleMessage}
- >
- Message
-
-
- )}
-
-
- {server && }
-
- {creator ? : }
- {userId !== myUserId && }
- {userId !== myUserId && }
+
+ {/* Display Name */}
+
+ {getMemberDisplayName(room, userId)}
+
+
+ {/* Username */}
+
+ {userId}
+
+
+ {/* Status Pill */}
+ {presence?.status && (
+
+
+ {presence.status}
+
+ )}
+
+ {/* Chips Row */}
+
+ {server && }
+
+ {creator ? : }
+
+ {/* Message Button */}
+ {userId !== myUserId && (
+ }
+ onClick={handleMessage}
+ >
+ Message
+
+ )}
+
+ {/* Additional Chips if needed */}
+ {userId !== myUserId && (
+
+
+
+
+ )}
+
+ {hasBottomContent &&
{ignored && }
{member && membership === Membership.Ban && (
-
+ }
);
}
diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts
index 4a4d82b..0ca6924 100644
--- a/src/app/components/user-profile/styles.css.ts
+++ b/src/app/components/user-profile/styles.css.ts
@@ -16,7 +16,7 @@ export const UserHero = style({
export const UserHeroCoverContainer = style({
position: 'relative',
- height: toRem(96),
+ height: toRem(140),
overflow: 'hidden',
});
export const UserHeroCover = style({
@@ -39,15 +39,16 @@ export const UserHeroAvatarContainer = style({
});
export const UserAvatarContainer = style({
position: 'absolute',
- left: config.space.S400,
+ left: '50%',
top: 0,
- transform: 'translateY(-50%)',
+ transform: 'translate(-50%, -50%)',
backgroundColor: color.Surface.Container,
});
export const UserStatusBubble = style({
position: 'absolute',
- left: `calc(${config.space.S400} + ${toRem(64)} + ${config.space.S200})`,
+ left: '50%',
+ transform: 'translateX(-50%)',
bottom: toRem(-8),
zIndex: 2,
});
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index 0db68fa..307eb7a 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -37,7 +37,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
-import { nameInitials } from '../../../utils/common';
+import { nameInitials, getContrastingTextColor } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
@@ -570,7 +570,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject
@@ -682,121 +682,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject
)}
- {/* Status Bubble - sibling to banner button, NOT inside it */}
- setHoveredArea('status')}
- onMouseLeave={() => setHoveredArea(null)}
- style={{
- position: 'absolute',
- left: `calc(${config.space.S400} + ${toRem(64)} + ${config.space.S300})`,
- bottom: toRem(8),
- zIndex: 2,
- backgroundColor: color.Surface.Container,
- border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
- padding: `${toRem(4)} ${toRem(8)}`,
- borderRadius: toRem(12),
- maxWidth: toRem(200),
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
- }}
- >
- {isEditingStatus ? (
- ) =>
- setEditedStatus(e.target.value)
- }
- onKeyDown={handleStatusKeyDown}
- onBlur={handleStatusSave}
- disabled={savingStatus}
- placeholder="Set a custom status..."
- />
- ) : (
-
- {
- setEditedStatus(presence?.status || '');
- setIsEditingStatus(true);
- }}
- style={{
- background: 'none',
- border: 'none',
- padding: 0,
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- }}
- >
-
- {presence?.status || 'Click to set a custom status...'}
-
-
- {presence?.status && hoveredArea === 'status' && (
- <>
-
- {
- e.preventDefault();
- e.stopPropagation();
- handleRemoveStatus();
- }}
- style={{
- backgroundColor: color.Critical.Main,
- borderRadius: toRem(8),
- padding: toRem(2),
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- cursor: 'pointer',
- border: 'none',
- }}
- >
-
-
- >
- )}
-
- )}
-
- {/* Avatar and Name Section */}
+ {/* Avatar Container - matches UserHeroAvatarContainer height */}
- {/* Avatar */}
+ {/* Avatar - centered */}
setHoveredArea('avatar')}
@@ -806,7 +710,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject
)}
+
- {/* Name Section - offset for avatar */}
-
-
-
- {/* Color Picker - left of name */}
-
-
-
- {
- setLocalColor(e.target.value);
- setColorError(undefined);
- }}
- />
-
-
- {colorError && (
-
- {colorError}
-
- )}
-
- }
- onRemove={userColor ? handleColorRemove : undefined}
- >
- {(onOpen) => (
-
+ {/* Display Name with Color Picker */}
+
+
+
+
+ {
+ setLocalColor(e.target.value);
+ setColorError(undefined);
}}
/>
+
+
+ {colorError && (
+
+ {colorError}
+
)}
-
- {isEditingName ? (
- setEditedName(e.target.value)}
- onKeyDown={handleNameKeyDown}
- onBlur={handleNameSave}
- variant="Background"
- size="400"
- disabled={savingName}
- style={{
- fontSize: 'var(--token.font-size.H400)',
- fontWeight: 'var(--token.font-weight.H400)',
- padding: `${toRem(4)} ${toRem(8)}`,
- }}
- />
- ) : (
+
+ }
+ onRemove={userColor ? handleColorRemove : undefined}
+ >
+ {(onOpen) => (
+
+ )}
+
+ {isEditingName ? (
+ setEditedName(e.target.value)}
+ onKeyDown={handleNameKeyDown}
+ onBlur={handleNameSave}
+ variant="Background"
+ size="400"
+ disabled={savingName}
+ style={{
+ fontSize: 'var(--token.font-size.H400)',
+ fontWeight: 'var(--token.font-weight.H400)',
+ padding: `${toRem(4)} ${toRem(8)}`,
+ textAlign: 'center',
+ }}
+ />
+ ) : (
+ {
+ setEditedName(profile.displayName || getMxIdLocalPart(userId) || userId);
+ setIsEditingName(true);
+ }}
+ onMouseEnter={() => setHoveredArea('name')}
+ onMouseLeave={() => setHoveredArea(null)}
+ style={{
+ border: 'none',
+ background: 'none',
+ cursor: 'pointer',
+ padding: `${toRem(4)} ${toRem(8)}`,
+ margin: `${toRem(-4)} ${toRem(-8)}`,
+ position: 'relative',
+ }}
+ >
+
+ {profile.displayName || getMxIdLocalPart(userId) || userId}
+
+ {hoveredArea === 'name' && (
{
- setEditedName(profile.displayName || getMxIdLocalPart(userId) || userId);
- setIsEditingName(true);
- }}
- onMouseEnter={() => setHoveredArea('name')}
- onMouseLeave={() => setHoveredArea(null)}
style={{
- border: 'none',
- background: 'none',
- cursor: 'pointer',
- padding: `${toRem(4)} ${toRem(8)}`,
- margin: `${toRem(-4)} ${toRem(-8)}`,
- position: 'relative',
- textAlign: 'left',
+ position: 'absolute',
+ top: 0,
+ right: toRem(-32),
+ backgroundColor: color.Surface.Container,
+ borderRadius: toRem(20),
+ padding: toRem(6),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ pointerEvents: 'none',
+ border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
>
-
- {profile.displayName || getMxIdLocalPart(userId) || userId}
-
- {hoveredArea === 'name' && (
-
-
-
- )}
+
)}
-
-
+
+ {/* Username */}
+
+ {userId}
+
+
+ {/* Status Pill - Editable */}
+ setHoveredArea('status')}
+ onMouseLeave={() => setHoveredArea(null)}
+ style={{
+ backgroundColor: previewBorderColor || color.Surface.Container,
+ border: previewBorderColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
+ padding: `${toRem(6)} ${toRem(10)}`,
+ borderRadius: toRem(16),
+ maxWidth: toRem(250),
+ cursor: 'pointer',
+ }}
+ >
+ {isEditingStatus ? (
+ ) =>
+ setEditedStatus(e.target.value)
+ }
+ onKeyDown={handleStatusKeyDown}
+ onBlur={handleStatusSave}
+ disabled={savingStatus}
+ placeholder="Set a custom status..."
+ />
+ ) : (
+
+ {
+ setEditedStatus(presence?.status || '');
+ setIsEditingStatus(true);
+ }}
+ style={{
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ }}
>
- {userId}
-
+
+ {presence?.status || 'Click to set a custom status...'}
+
+ {presence?.status && hoveredArea === 'status' && (
+ <>
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handleRemoveStatus();
+ }}
+ style={{
+ backgroundColor: color.Critical.Main,
+ borderRadius: toRem(8),
+ padding: toRem(2),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ border: 'none',
+ }}
+ >
+
+
+ >
+ )}
+ )}
+
+
+ {/* Chips Row - Placeholder chips */}
+
+
+
+ ruv.wtf
+
+
+
+
+ Share
+
+
+
+
+ Admin
+
-
+
{/* Profile Style Settings */}
{
if (trimmedContent === '') return [];
return trimmedContent.split(' ');
};
+
+/**
+ * Calculate contrasting text color (black or white) for a given background color
+ * @param bgColor - Hex color string (e.g., '#RRGGBB' or '#RRGGBBAA')
+ * @returns '#000000' for light backgrounds, '#FFFFFF' for dark backgrounds
+ */
+export const getContrastingTextColor = (bgColor: string): string => {
+ // Remove # if present
+ const hex = bgColor.replace('#', '');
+
+ // Parse RGB values (ignore alpha if present)
+ const r = parseInt(hex.substring(0, 2), 16);
+ const g = parseInt(hex.substring(2, 4), 16);
+ const b = parseInt(hex.substring(4, 6), 16);
+
+ // Calculate relative luminance using WCAG formula
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+
+ // Return black for light backgrounds, white for dark backgrounds
+ return luminance > 0.5 ? '#000000' : '#FFFFFF';
+};