diff --git a/src/app/components/user-profile/CreatorChip.tsx b/src/app/components/user-profile/CreatorChip.tsx index f59d2ae..be39254 100644 --- a/src/app/components/user-profile/CreatorChip.tsx +++ b/src/app/components/user-profile/CreatorChip.tsx @@ -14,8 +14,9 @@ import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { SpaceSettingsPage } from '../../state/spaceSettings'; import { RoomSettingsPage } from '../../state/roomSettings'; +import { getContrastingTextColor } from '../../utils/common'; -export function CreatorChip() { +export function CreatorChip({ borderColor }: { borderColor?: string }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const room = useRoom(); @@ -91,8 +92,13 @@ export function CreatorChip() { after={tagIconSrc ? : undefined} onClick={open} aria-pressed={!!cords} + style={borderColor ? { + backgroundColor: borderColor, + border: 'none', + color: getContrastingTextColor(borderColor) + } : undefined} > - + {tag.name} diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a675830..6ff195e 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -43,6 +43,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; +import { getContrastingTextColor } from '../../utils/common'; type SelfDemoteAlertProps = { power: number; @@ -144,7 +145,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) ); } -export function PowerChip({ userId }: { userId: string }) { +export function PowerChip({ userId, borderColor }: { userId: string; borderColor?: string }) { const mx = useMatrixClient(); const room = useRoom(); const space = useSpaceOptionally(); @@ -332,8 +333,13 @@ export function PowerChip({ userId }: { userId: string }) { after={tagIconSrc ? : undefined} onClick={open} aria-pressed={!!cords} + style={borderColor ? { + backgroundColor: borderColor, + border: 'none', + color: getContrastingTextColor(borderColor) + } : undefined} > - + {tag.name} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index dff0c2a..2d81067 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -42,8 +42,9 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; +import { getContrastingTextColor } from '../../utils/common'; -export function ServerChip({ server }: { server: string }) { +export function ServerChip({ server, borderColor }: { server: string; borderColor?: string }) { const mx = useMatrixClient(); const myServer = getMxIdServer(mx.getSafeUserId()); const navigate = useNavigate(); @@ -134,8 +135,13 @@ export function ServerChip({ server }: { server: string }) { } onClick={open} aria-pressed={!!cords} + style={borderColor ? { + backgroundColor: borderColor, + border: 'none', + color: getContrastingTextColor(borderColor) + } : undefined} > - + {server} @@ -143,7 +149,7 @@ export function ServerChip({ server }: { server: string }) { ); } -export function ShareChip({ userId }: { userId: string }) { +export function ShareChip({ userId, borderColor }: { userId: string; borderColor?: string }) { const [cords, setCords] = useState(); const [copied, setCopied] = useTimeoutToggle(); @@ -216,8 +222,13 @@ export function ShareChip({ userId }: { userId: string }) { } onClick={open} aria-pressed={!!cords} + style={borderColor ? { + backgroundColor: borderColor, + border: 'none', + color: getContrastingTextColor(borderColor) + } : undefined} > - + Share @@ -231,7 +242,7 @@ type MutualRoomsData = { directs: Room[]; }; -export function MutualRoomsChip({ userId }: { userId: string }) { +export function MutualRoomsChip({ userId, borderColor }: { userId: string; borderColor?: string }) { const mx = useMatrixClient(); const mutualRoomSupported = useMutualRoomsSupport(); const mutualRoomsState = useMutualRooms(userId); @@ -409,8 +420,13 @@ export function MutualRoomsChip({ userId }: { userId: string }) { } onClick={open} aria-pressed={!!cords} + style={borderColor ? { + backgroundColor: borderColor, + border: 'none', + color: getContrastingTextColor(borderColor) + } : undefined} > - + {mutualRoomsState.status === AsyncStatus.Success && `${mutualRoomsState.data.length} Mutual Rooms`} {mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'} @@ -437,7 +453,7 @@ export function IgnoredUserAlert() { ); } -export function OptionsChip({ userId }: { userId: string }) { +export function OptionsChip({ userId, borderColor }: { userId: string; borderColor?: string }) { const mx = useMatrixClient(); const [cords, setCords] = useState(); @@ -503,11 +519,21 @@ export function OptionsChip({ userId }: { userId: string }) { } > - + {ignoring ? ( ) : ( - + )} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 553e297..c18e44b 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { Avatar, Box, - color, Icon, Icons, Modal, @@ -38,11 +37,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro 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 ( -
+
} @@ -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 && ( - - - - )} - - - {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 && ( + + )} + + {/* 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'; +};