All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
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.
1083 lines
38 KiB
TypeScript
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} />
|
|
);
|
|
}
|