Files
cinny/src/app/features/settings/account/Profile.tsx
litruv 0cbc5d9a1c
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Add Discord collectibles to profiles, messages, and settings.
Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
2026-08-24 00:54:48 +10:00

1083 lines
38 KiB
TypeScript

import React, {
ChangeEvent,
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } 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 { 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 { useUserColorPreference } from '../../../hooks/useUserColor';
import { useUserBanner } from '../../../hooks/useUserBanner';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { useTheme, ThemeKind } from '../../../hooks/useTheme';
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import { getCurrentAccessToken } from '../../../utils/auth';
import { CollectiblesSection } from './CollectiblesSection';
import { useUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import {
ColorPreference,
hasColorPreference,
resolveColorForTheme,
} from '../../../utils/profileFields';
/**
* Banner upload component for user's profile banner (MSC4427 via MSC4133).
*/
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 [collectibles, updateCollectible] = useUserCollectibles();
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;
const avatarDecorationUrl = useMemo(() => {
const decoration = collectibles.avatar_decoration;
if (!decoration) return undefined;
const assetUrls: Record<string, string | undefined> = {};
for (const [role, mxc] of Object.entries(decoration.assets)) {
assetUrls[`avatar_decoration:${role}`] =
mxcUrlToHttp(mx, mxc, useAuthentication) ?? undefined;
}
return pickAvatarDecorationUrl(assetUrls);
}, [collectibles.avatar_decoration, mx, useAuthentication]);
// 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 theme = useTheme();
const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference();
const [localOnDark, setLocalOnDark] = useState('#ffd9f5');
const [localOnLight, setLocalOnLight] = useState('#440000');
const [savingColor, setSavingColor] = useState(false);
const [colorError, setColorError] = useState<string>();
useEffect(() => {
if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark);
if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light);
}, [colorPreference]);
const handleColorSave = async () => {
setSavingColor(true);
setColorError(undefined);
try {
await setColorPreference({ on_dark: localOnDark, on_light: localOnLight });
} catch (e) {
setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).');
}
setSavingColor(false);
};
const handleColorRemove = async () => {
setSavingColor(true);
setColorError(undefined);
try {
await setColorPreference(undefined);
setLocalOnDark('#ffd9f5');
setLocalOnLight('#440000');
} catch (e) {
setColorError('Failed to remove colors');
}
setSavingColor(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;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
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 {
await mx.setAvatarUrl(upload.mxc);
const userId = mx.getUserId();
if (userId) {
const user = mx.getUser(userId);
if (user && user.avatarUrl !== upload.mxc) {
user.setAvatarUrl(upload.mxc);
}
}
setAvatarFile(undefined);
await syncUserAvatar();
} catch (e: any) {
setError(`Failed to save avatar: ${e?.message || 'Unknown error'}`);
}
setSaving(false);
},
[mx, 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);
};
const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight };
const previewProfileColor =
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
const hasSavedColors = hasColorPreference(colorPreference);
const colorPickerSize = toRem(200);
const colorPreviewPlateStyle = {
width: '100%',
boxSizing: 'border-box' as const,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(8),
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
lineHeight: 1.2,
};
const displayName = profile.displayName || getMxIdLocalPart(userId) || userId;
return (
<Box direction="Column" gap="300" style={{ width: '100%', alignItems: 'center' }}>
<Box
direction="Column"
style={{
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '60%',
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
style={{
position: 'relative',
width: toRem(76),
height: toRem(76),
}}
>
<AvatarPresence
badge={
presence && (
<PresenceBadge presence={presence.presence} status={presence.status} />
)
}
>
<Box
as="button"
onClick={handleAvatarClick}
style={{
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
}}
>
<Avatar
size="500"
style={{
width: toRem(76),
height: toRem(76),
...(avatarUrl
? { outline: 'none', border: 'none', boxShadow: 'none' }
: {
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}),
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</Box>
</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 */}
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
marginTop: toRem(-36),
textAlign: 'center',
}}
>
{/* Display Name */}
<Box style={{ width: '100%' }}>
{isEditingName ? (
<Input
autoFocus
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameSave}
variant="Background"
size="400"
disabled={savingName}
style={{
width: '100%',
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={{
width: '100%',
border: 'none',
background: 'none',
cursor: 'pointer',
padding: `${toRem(4)} ${toRem(8)}`,
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
lineHeight: 1.2,
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={profile.displayName || getMxIdLocalPart(userId)}
style={{
color: previewProfileColor,
textShadow: previewTextShadow,
textAlign: 'center',
maxWidth: '100%',
}}
>
{profile.displayName || getMxIdLocalPart(userId) || userId}
</Text>
{hoveredArea === 'name' && (
<Box
style={{
position: 'absolute',
top: '50%',
right: 0,
transform: 'translateY(-50%)',
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: color.Surface.Container,
border: `${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',
}}
>
{presence?.status || 'Click to set a custom status...'}</Text>
</Box>
{presence?.status && hoveredArea === 'status' && (
<>
<Icon size="50" src={Icons.Pencil} />
<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: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text size="B300">ruv.wtf</Text>
</Box>
<Box
style={{
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text size="B300">Share</Text>
</Box>
<Box
style={{
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text size="B300">Admin</Text>
</Box>
</Box>
</Box>
</Box>
{/* MSC4522 username colors */}
<Box
direction="Column"
gap="300"
style={{
padding: config.space.S400,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
boxSizing: 'border-box',
}}
>
<Text size="H6">Username colors</Text>
<Box direction="Row" gap="400" wrap="Wrap" alignItems="Start">
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
<Text size="T300">On dark themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
<Box
style={{
...colorPreviewPlateStyle,
backgroundColor: '#262626',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName}
style={{
color: localOnDark,
textShadow: `0 1px 4px ${getTextShadowColor(localOnDark)}`,
textAlign: 'center',
maxWidth: '100%',
}}
>
{displayName}
</Text>
</Box>
<HexColorPicker
color={localOnDark}
onChange={(c) => {
setLocalOnDark(c);
setColorError(undefined);
}}
style={{ width: '100%', height: colorPickerSize }}
/>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
value={localOnDark}
onChange={(e) => {
setLocalOnDark(e.target.value);
setColorError(undefined);
}}
/>
</Box>
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
<Text size="T300">On light themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
<Box
style={{
...colorPreviewPlateStyle,
backgroundColor: '#F0F0F0',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName}
style={{
color: localOnLight,
textShadow: `0 1px 4px ${getTextShadowColor(localOnLight)}`,
textAlign: 'center',
maxWidth: '100%',
}}
>
{displayName}
</Text>
</Box>
<HexColorPicker
color={localOnLight}
onChange={(c) => {
setLocalOnLight(c);
setColorError(undefined);
}}
style={{ width: '100%', height: colorPickerSize }}
/>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
value={localOnLight}
onChange={(e) => {
setLocalOnLight(e.target.value);
setColorError(undefined);
}}
/>
</Box>
</Box>
<Box gap="200" alignItems="Center" wrap="Wrap">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleColorSave}
disabled={savingColor || colorLoading}
>
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
</Button>
{hasSavedColors && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleColorRemove}
disabled={savingColor}
>
<Text size="B300">Remove colors</Text>
</Button>
)}
</Box>
{colorError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
)}
</Box>
<Box
direction="Column"
gap="300"
style={{
padding: config.space.S400,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
boxSizing: 'border-box',
}}
>
<Text size="H6">Discord profile overlays</Text>
<CollectiblesSection collectibles={collectibles} onApply={updateCollectible} />
</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} />
);
}