Files
cinny/src/app/features/settings/account/Profile.tsx

1261 lines
44 KiB
TypeScript

import React, {
ChangeEvent,
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
color,
toRem,
} from 'folds';
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { AngleSelector } from '../../../components/AngleSelector';
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID from '../../../../util/colorMXID';
import {
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
} from '../../../utils/imageMetadata';
/**
* Banner upload component for user's profile banner
* Stored in avatar image metadata, visible to other Paarrot users
*/
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
const presence = useUserPresence(userId);
const [userBanner, setUserBanner, loading] = useUserBanner();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
const [bannerBlobUrl, setBannerBlobUrl] = useState<string>();
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Helper to sync Matrix client's user object with server after avatar changes
const syncUserAvatar = useCallback(async () => {
try {
const updatedProfile = await mx.getProfileInfo(userId);
const user = mx.getUser(userId);
if (user) {
// Manually sync avatar URL to trigger UserEvent.AvatarUrl listeners
if (updatedProfile.avatar_url !== user.avatarUrl) {
user.setAvatarUrl(updatedProfile.avatar_url || '');
}
// Manually sync display name to trigger UserEvent.DisplayName listeners
if (updatedProfile.displayname !== user.displayName) {
user.setDisplayName(updatedProfile.displayname || '');
}
}
setRefreshTrigger((prev) => prev + 1);
} catch (err) {
console.error('Failed to sync user profile:', err);
}
}, [mx, userId]);
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
// Larger avatar URL for the blurred cover fallback
const avatarCoverUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
: undefined;
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
const [userColor, setUserColor] = useUserColor();
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
const [savingColor, setSavingColor] = useState(false);
const [colorError, setColorError] = useState<string>();
useEffect(() => {
if (userColor) {
setLocalColor(userColor);
}
}, [userColor]);
const handleColorSave = async () => {
setSavingColor(true);
setColorError(undefined);
try {
await setUserColor(localColor);
// Sync user object to propagate changes throughout app
await syncUserAvatar();
} catch (e) {
setColorError('Failed to save color');
}
setSavingColor(false);
};
const handleColorRemove = async () => {
setSavingColor(true);
setColorError(undefined);
try {
await setUserColor(undefined);
} catch (e) {
setColorError('Failed to remove color');
}
setSavingColor(false);
};
// Profile style settings (border color and gradient)
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
// Initialize with transparent alpha so saved values show in preview until user edits
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
const [savingStyle, setSavingStyle] = useState(false);
const [styleError, setStyleError] = useState<string>();
// Track if user has started editing (to show local values in preview)
const [editingBorder, setEditingBorder] = useState(false);
const [editingGradient, setEditingGradient] = useState(false);
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
const rgbaToHex = (rgba: RgbaColor): string => {
const r = rgba.r.toString(16).padStart(2, '0');
const g = rgba.g.toString(16).padStart(2, '0');
const b = rgba.b.toString(16).padStart(2, '0');
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
return `#${r}${g}${b}${a}`;
};
// Helper to convert hex with alpha to RGBA
const hexToRgba = (hex: string): RgbaColor => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
if (result) {
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
};
}
return { r: 59, g: 130, b: 246, a: 1 };
};
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
const parseDirectionDegrees = (direction: string): number => {
const degMatch = direction.match(/^(\d+)deg$/i);
if (degMatch) return parseInt(degMatch[1], 10);
// Fallback for legacy "to X" format
const keywordMap: Record<string, number> = {
'to top': 0,
'to top right': 45,
'to right': 90,
'to bottom right': 135,
'to bottom': 180,
'to bottom left': 225,
'to left': 270,
'to top left': 315,
};
return keywordMap[direction.toLowerCase()] ?? 180;
};
// Sync local style state with loaded profile style
useEffect(() => {
if (profileStyle.avatarBorderColor) {
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
}
if (profileStyle.gradient) {
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
}
}, [profileStyle]);
const handleBorderColorSave = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
await syncUserAvatar();
setEditingBorder(false);
} catch (e) {
setStyleError('Failed to save border color');
}
setSavingStyle(false);
};
const handleBorderColorRemove = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ avatarBorderColor: undefined });
await syncUserAvatar();
setEditingBorder(false);
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
} catch (e) {
setStyleError('Failed to remove border color');
}
setSavingStyle(false);
};
const handleGradientSave = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({
gradient: {
direction: `${localGradientDirection}deg`,
startColor: rgbaToHex(localGradientStart),
stopColor: rgbaToHex(localGradientStop),
},
});
await syncUserAvatar();
setEditingGradient(false);
} catch (e) {
setStyleError('Failed to save gradient');
}
setSavingStyle(false);
};
const handleGradientRemove = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ gradient: undefined });
await syncUserAvatar();
setEditingGradient(false);
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
setLocalGradientDirection(180);
} catch (e) {
setStyleError('Failed to remove gradient');
}
setSavingStyle(false);
};
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(profile.displayName || '');
const [savingName, setSavingName] = useState(false);
const [isEditingStatus, setIsEditingStatus] = useState(false);
const [editedStatus, setEditedStatus] = useState(presence?.status || '');
const [savingStatus, setSavingStatus] = useState(false);
// Update edited name when profile changes
useEffect(() => {
if (!isEditingName) {
setEditedName(profile.displayName || '');
}
}, [profile.displayName, isEditingName]);
// Update edited status when presence changes
useEffect(() => {
if (!isEditingStatus) {
setEditedStatus(presence?.status || '');
}
}, [presence?.status, isEditingStatus]);
const handleAvatarClick = () => {
pickAvatarFile('image/*');
};
const handleNameSave = async () => {
if (!editedName.trim() || savingName) return;
setSavingName(true);
try {
await mx.setDisplayName(editedName);
setIsEditingName(false);
// Force refresh to update name display
setTimeout(() => syncUserAvatar(), 100);
} catch (err) {
console.error('Failed to update name:', err);
}
setSavingName(false);
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleNameSave();
} else if (e.key === 'Escape') {
setEditedName(profile.displayName || '');
setIsEditingName(false);
}
};
const handleStatusSave = async () => {
if (savingStatus) return;
setSavingStatus(true);
try {
await mx.setPresence({
presence: presence?.presence || 'online',
status_msg: editedStatus || undefined,
});
setIsEditingStatus(false);
// Force refresh to update status display
setTimeout(() => setRefreshTrigger((prev) => prev + 1), 100);
} catch (err) {
console.error('Failed to update status:', err);
}
setSavingStatus(false);
};
const handleStatusKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleStatusSave();
} else if (e.key === 'Escape') {
setEditedStatus(presence?.status || '');
setIsEditingStatus(false);
}
};
const handleRemoveStatus = async () => {
if (savingStatus) return;
setSavingStatus(true);
try {
await mx.setPresence({
presence: presence?.presence || 'online',
status_msg: undefined,
});
setEditedStatus('');
setIsEditingStatus(false);
// Force refresh to update UI
setTimeout(() => setRefreshTrigger((prev) => prev + 1), 100);
} catch (err) {
console.error('Failed to remove status:', err);
}
setSavingStatus(false);
};
const handleRemoveAvatar = async () => {
try {
await mx.setAvatarUrl('');
// Sync user object to propagate changes throughout app
await syncUserAvatar();
} catch (err) {
console.error('Failed to remove avatar:', err);
}
};
// Load banner as blob URL
useEffect(() => {
let blobUrl: string | undefined;
const loadBanner = async () => {
if (!userBanner) {
setBannerBlobUrl(undefined);
return;
}
const bannerHttpUrl = mxcUrlToHttp(mx, userBanner, useAuthentication);
if (!bannerHttpUrl) {
setBannerBlobUrl(undefined);
return;
}
const accessToken = mx.getAccessToken();
const headers: HeadersInit = {};
if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
try {
const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) {
console.warn('Failed to fetch banner preview:', response.status);
setBannerBlobUrl(undefined);
return;
}
const blob = await response.blob();
blobUrl = URL.createObjectURL(blob);
setBannerBlobUrl(blobUrl);
} catch (err) {
console.error('Error loading banner preview:', err);
setBannerBlobUrl(undefined);
}
};
loadBanner();
return () => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [mx, useAuthentication, userBanner, refreshTrigger]);
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
// Avatar-specific upload
const [avatarFile, setAvatarFile] = useState<File>();
const avatarUploadAtom = useMemo(() => {
if (avatarFile) return createUploadAtom(avatarFile);
return undefined;
}, [avatarFile]);
const pickAvatarFile = useFilePicker(setAvatarFile, false);
// Warn before leaving when an upload or save is in progress
useEffect(() => {
if (!imageFile && !avatarFile && !saving) return undefined;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [imageFile, avatarFile, saving]);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
async (upload: UploadSuccess) => {
const { mxc } = upload;
console.log('[ProfileBanner] Banner image uploaded, MXC:', mxc);
setSaving(true);
setError(undefined);
try {
console.log('[ProfileBanner] Calling setUserBanner...');
await setUserBanner(mxc);
console.log('[ProfileBanner] setUserBanner completed successfully');
handleRemoveUpload();
// Sync user object to propagate changes throughout app
await syncUserAvatar();
} catch (e: any) {
const errorMsg = e?.message || 'Unknown error';
console.error('[ProfileBanner] setUserBanner failed:', e);
setError(`Failed to save banner: ${errorMsg}`);
}
setSaving(false);
},
[setUserBanner, handleRemoveUpload, syncUserAvatar]
);
const handleAvatarUploaded = useCallback(
async (upload: UploadSuccess) => {
setSaving(true);
setError(undefined);
try {
// Fetch the raw uploaded image so we can embed existing banner/color metadata
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
const accessToken = mx.getAccessToken();
const response = await fetch(httpUrl, {
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
const avatarData = await response.arrayBuffer();
const format = detectImageFormat(avatarData);
if (format === 'unknown') throw new Error('Unsupported avatar image format');
// Re-embed the existing banner URL and profile color into the new avatar
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
};
const modifiedData = embedMetadataInImage(avatarData, metadata);
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([modifiedData], { type: mimeType });
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
await mx.setAvatarUrl(uploadResponse.content_uri);
const userId = mx.getUserId();
if (userId) {
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
}
}
setAvatarFile(undefined);
await syncUserAvatar();
} catch (e: any) {
setError(`Failed to save avatar: ${e?.message || 'Unknown error'}`);
}
setSaving(false);
},
[mx, useAuthentication, userBanner, userColor, syncUserAvatar]
);
const handleRemoveBanner = async () => {
setSaving(true);
setError(undefined);
try {
await setUserBanner(undefined);
// Sync user object to propagate changes throughout app
await syncUserAvatar();
} catch (e) {
setError('Failed to remove banner.');
}
setSaving(false);
};
// Build gradient CSS for preview (shows local editing values when user is editing,
// otherwise falls back to saved profile style)
const previewGradient = editingGradient
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
: profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
// Build border color for preview (shows local editing value when user is editing,
// otherwise falls back to saved profile style)
const previewBorderColor = editingBorder
? rgbaToHex(localBorderColor)
: profileStyle.avatarBorderColor;
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
const previewProfileColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : (userColor || colorMXID(userId));
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
return (
<Box direction="Column" gap="300">
<Box
direction="Column"
style={{
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: toRem(340),
overflow: 'hidden',
position: 'relative',
}}
>
{/* Banner/Cover wrapper - provides positioning context so status bubble is not inside the button */}
<Box
style={{
position: 'relative',
height: toRem(140),
width: '100%',
}}
>
<Box
as="button"
onClick={() => pickFile('image/*')}
onMouseEnter={() => setHoveredArea('banner')}
onMouseLeave={() => setHoveredArea(null)}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
cursor: 'pointer',
border: 'none',
padding: 0,
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId),
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
display: 'flex',
alignItems: 'center',
}}
>
{bannerBlobUrl ? (
<img
src={bannerBlobUrl}
alt="Banner"
draggable="false"
style={{
height: '100%',
width: '100%',
objectFit: 'cover',
}}
/>
) : (
authenticatedCoverUrl && (
<img
src={authenticatedCoverUrl}
alt="Cover"
draggable="false"
style={{
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'blur(16px)',
transform: 'scale(2)',
}}
/>
)
)}
{/* Banner hover overlay - right side action bar */}
{hoveredArea === 'banner' && (
<Box
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
padding: `0 ${toRem(12)}`,
background: 'linear-gradient(to left, rgba(0,0,0,0.6), transparent)',
gap: toRem(8),
}}
>
<Box
style={{
backgroundColor: color.Surface.Container,
borderRadius: toRem(16),
padding: toRem(8),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon size="100" src={Icons.Pencil} />
</Box>
{userBanner && (
<Box
as="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveBanner();
}}
style={{
backgroundColor: color.Critical.Main,
borderRadius: toRem(16),
padding: toRem(8),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
border: 'none',
}}
>
<Icon size="100" src={Icons.Cross} fill="white" />
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Avatar Container - matches UserHeroAvatarContainer height */}
<Box
style={{
position: 'relative',
height: toRem(29),
}}
>
{/* Avatar - centered */}
<Box
style={{
position: 'absolute',
left: '50%',
top: 0,
transform: 'translate(-50%, -50%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: toRem(8),
}}
onMouseEnter={() => setHoveredArea('avatar')}
onMouseLeave={() => setHoveredArea(null)}
>
<Box
as="button"
onClick={handleAvatarClick}
style={{
backgroundColor: previewBorderColor || color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
}}
>
<AvatarPresence
badgeBackgroundColor={previewBorderColor}
badge={
presence && (
<PresenceBadge presence={presence.presence} status={presence.status} />
)
}
>
<Avatar
size="500"
style={{
width: toRem(72),
height: toRem(72),
outline: previewBorderColor
? `${toRem(4)} solid ${previewBorderColor}`
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
</Box>
{/* Avatar action icons - shown on hover */}
{hoveredArea === 'avatar' && (
<Box
style={{
display: 'flex',
alignItems: 'center',
gap: toRem(4),
backgroundColor: color.Surface.Container,
borderRadius: toRem(16),
padding: `${toRem(4)} ${toRem(8)}`,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
marginTop: toRem(4),
}}
>
<Box
as="button"
onClick={handleAvatarClick}
style={{
background: 'none',
border: 'none',
padding: toRem(4),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: toRem(8),
}}
>
<Icon size="50" src={Icons.Pencil} />
</Box>
{avatarUrl && (
<Box
as="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveAvatar();
}}
style={{
backgroundColor: color.Critical.Main,
borderRadius: toRem(8),
padding: toRem(4),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
cursor: 'pointer',
}}
>
<Icon size="50" src={Icons.Cross} fill="white" />
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Profile Info Section - matches UserRoomProfile gradient section */}
<Box
direction="Column"
gap="400"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
marginTop: toRem(-36),
background: previewGradient,
textAlign: 'center',
}}
>
{/* Display Name with Color Picker */}
<Box alignItems="Center" gap="200" justifyContent="Center">
<HexColorPickerPopOut
picker={
<Box direction="Column" gap="200">
<HexColorPicker color={localColor} onChange={setLocalColor} />
<Box gap="100" alignItems="Center">
<Input
size="300"
variant="Secondary"
style={{ width: toRem(100) }}
value={localColor}
onChange={(e) => {
setLocalColor(e.target.value);
setColorError(undefined);
}}
/>
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleColorSave}
disabled={savingColor}
>
<Text size="B300">{savingColor ? 'Saving...' : 'Save'}</Text>
</Button>
</Box>
{colorError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{colorError}
</Text>
)}
</Box>
}
onRemove={userColor ? handleColorRemove : undefined}
>
{(onOpen) => (
<Box
as="button"
onClick={onOpen}
disabled={savingColor}
style={{
width: toRem(24),
height: toRem(24),
borderRadius: toRem(6),
backgroundColor: userColor ?? localColor,
border: 'none',
cursor: 'pointer',
flexShrink: 0,
}}
/>
)}
</HexColorPickerPopOut>
{isEditingName ? (
<Input
autoFocus
value={editedName}
onChange={(e) => 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',
}}
/>
) : (
<Box
as="button"
onClick={() => {
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',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={profile.displayName || getMxIdLocalPart(userId)}
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
>
{profile.displayName || getMxIdLocalPart(userId) || userId}
</Text>
{hoveredArea === 'name' && (
<Box
style={{
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}`,
}}
>
<Icon size="50" src={Icons.Pencil} />
</Box>
)}
</Box>
)}
</Box>
{/* Username */}
<Text
size="T200"
className={BreakWord}
title={userId}
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
>
{userId}
</Text>
{/* Status Pill - Editable */}
<Box
onMouseEnter={() => setHoveredArea('status')}
onMouseLeave={() => setHoveredArea(null)}
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
cursor: 'pointer',
}}
>
{isEditingStatus ? (
<Input
style={{ width: toRem(150), minWidth: toRem(100), textAlign: 'center' }}
variant="Background"
size="300"
autoFocus
value={editedStatus}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setEditedStatus(e.target.value)
}
onKeyDown={handleStatusKeyDown}
onBlur={handleStatusSave}
disabled={savingStatus}
placeholder="Set a custom status..."
/>
) : (
<Box
style={{
display: 'flex',
alignItems: 'center',
gap: toRem(4),
justifyContent: 'center',
}}
>
<Box
as="button"
onClick={() => {
setEditedStatus(presence?.status || '');
setIsEditingStatus(true);
}}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
}}
>
<Text
size="T300"
className={BreakWord}
style={{
fontStyle: presence?.status ? 'normal' : 'italic',
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
{presence?.status || 'Click to set a custom status...'}</Text>
</Box>
{presence?.status && hoveredArea === 'status' && (
<>
<Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
<Box
as="button"
onClick={(e) => {
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',
}}
>
<Icon size="50" src={Icons.Cross} fill="white" />
</Box>
</>
)}
</Box>
)}
</Box>
{/* Chips Row - Placeholder chips */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<Box
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text
size="B300"
style={{
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
ruv.wtf
</Text>
</Box>
<Box
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text
size="B300"
style={{
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
Share
</Text>
</Box>
<Box
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text
size="B300"
style={{
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
Admin
</Text>
</Box>
</Box>
</Box>
</Box>
{/* Profile Style Settings */}
<Box
direction="Column"
gap="200"
style={{
padding: config.space.S300,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
}}
>
<Text size="H6">Profile Style</Text>
{/* Avatar Border Color */}
<Box direction="Column" gap="100">
<Text size="T300">Avatar Border Color</Text>
<Box gap="200" alignItems="Center" wrap="Wrap">
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
<Box direction="Column" gap="100">
<Text size="T200" style={{ opacity: 0.7 }}>
Preview: {rgbaToHex(localBorderColor)}
</Text>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: '50%',
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
backgroundColor: color.Surface.Container,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleBorderColorSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.avatarBorderColor && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleBorderColorRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
</Box>
</Box>
{/* Profile Gradient */}
<Box direction="Column" gap="100">
<Text size="T300">Profile Card Gradient</Text>
<Box gap="200" alignItems="Start" wrap="Wrap">
<Box direction="Column" gap="100">
<Text size="T200">Start Color</Text>
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">End Color</Text>
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">Direction</Text>
<AngleSelector
value={localGradientDirection}
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
/>
<Box
style={{
width: toRem(100),
height: toRem(60),
borderRadius: toRem(8),
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleGradientSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.gradient && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleGradientRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
</Box>
</Box>
{styleError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
)}
</Box>
{uploadAtom && (
<Box gap="200" direction="Column" style={{ width: '100%' }}>
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
)}
{avatarUploadAtom && (
<Box gap="200" direction="Column" style={{ width: '100%' }}>
<CompactUploadCardRenderer
uploadAtom={avatarUploadAtom}
onRemove={() => setAvatarFile(undefined)}
onComplete={handleAvatarUploaded}
/>
</Box>
)}
{saving && (
<Box gap="200" alignItems="Center">
<Spinner size="100" variant="Secondary" />
<Text size="T200">Saving to server</Text>
</Box>
)}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>
)}
</Box>
);
}
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const avatarRef = useRef<HTMLDivElement>(null);
const nameRef = useRef<HTMLDivElement>(null);
return (
<ProfileBanner avatarRef={avatarRef} nameRef={nameRef} />
);
}