All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
983 lines
34 KiB
TypeScript
983 lines
34 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 {
|
|
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 [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 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);
|
|
|
|
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: color.Surface.Container,
|
|
border: 'none',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
borderRadius: '50%',
|
|
}}
|
|
>
|
|
<AvatarPresence
|
|
badge={
|
|
presence && (
|
|
<PresenceBadge presence={presence.presence} status={presence.status} />
|
|
)
|
|
}
|
|
>
|
|
<Avatar
|
|
size="500"
|
|
style={{
|
|
width: toRem(72),
|
|
height: toRem(72),
|
|
outline: `${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 */}
|
|
<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.S300,
|
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
borderRadius: toRem(8),
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<Text size="H6">Username colors</Text>
|
|
<Text size="T200" style={{ opacity: 0.8 }}>
|
|
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
|
|
spec will see your chosen colors.
|
|
</Text>
|
|
|
|
<Box gap="400" wrap="Wrap">
|
|
<Box direction="Column" gap="200">
|
|
<Text size="T300">On dark themes</Text>
|
|
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
|
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
|
|
<Input
|
|
size="300"
|
|
variant="Secondary"
|
|
style={{ width: toRem(120) }}
|
|
value={localOnDark}
|
|
onChange={(e) => {
|
|
setLocalOnDark(e.target.value);
|
|
setColorError(undefined);
|
|
}}
|
|
/>
|
|
<Box
|
|
style={{
|
|
width: toRem(48),
|
|
height: toRem(48),
|
|
borderRadius: toRem(8),
|
|
backgroundColor: localOnDark,
|
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Box direction="Column" gap="200">
|
|
<Text size="T300">On light themes</Text>
|
|
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
|
|
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
|
|
<Input
|
|
size="300"
|
|
variant="Secondary"
|
|
style={{ width: toRem(120) }}
|
|
value={localOnLight}
|
|
onChange={(e) => {
|
|
setLocalOnLight(e.target.value);
|
|
setColorError(undefined);
|
|
}}
|
|
/>
|
|
<Box
|
|
style={{
|
|
width: toRem(48),
|
|
height: toRem(48),
|
|
borderRadius: toRem(8),
|
|
backgroundColor: localOnLight,
|
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
}}
|
|
/>
|
|
</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>
|
|
|
|
{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} />
|
|
);
|
|
}
|