Files
cinny/src/app/features/settings/account/Profile.tsx
T
jaredandClaude Opus 4.8 b1ee3ada98 DP16: add typed get/setAccountData helpers, remove ~19 any-casts
Add centralized typed helpers in src/app/utils/accountData.ts:
- getAccountData<T>(mx, eventType): T | undefined (returns content)
- setAccountData<T>(mx, eventType, content): Promise<void>

These wrap the single `as any` cast needed because matrix-js-sdk's typed
overloads reject the fork's custom account-data event names. Every call site
now stays fully typed on its content shape.

Route all account-data reads/writes that previously used
`(mx as any).getAccountData/setAccountData` or `mx.getAccountData(... as any)`
through the helpers (or, where a MatrixEvent is needed, through the existing
utils/room.ts getAccountData whose param is widened to accept string keys).
No behavior change: same event types, same content shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:14:35 -04:00

871 lines
27 KiB
TypeScript

import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
color,
Spinner,
PopOut,
RectCords,
} from 'folds';
import { Method } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } 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 { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
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);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload],
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
>
<Text size="B300">Upload</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton
size="300"
onClick={() => setAlertRemove(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx]),
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
aria-label="Display name"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
maxLength={255}
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
aria-label="Reset display name"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' },
{ label: '30 minutes', value: String(30 * 60 * 1000) },
{ label: '1 hour', value: String(60 * 60 * 1000) },
{ label: '4 hours', value: String(4 * 60 * 60 * 1000) },
{ label: '8 hours', value: String(8 * 60 * 60 * 1000) },
{ label: 'Until midnight', value: 'today' },
{ label: '1 day', value: String(24 * 60 * 60 * 1000) },
{ label: '7 days', value: String(7 * 24 * 60 * 60 * 1000) },
];
function getMsFromOption(value: string): number {
if (value === '0') return 0;
if (value === 'today') {
const eod = new Date();
eod.setHours(23, 59, 59, 999);
return eod.getTime() - Date.now();
}
return parseInt(value, 10);
}
function ProfileStatus() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const presence = useUserPresence(userId);
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
);
// True while the user has unsaved local edits — prevents a server presence
// echo from overwriting what the user is currently typing/inserting.
const statusDirtyRef = useRef(false);
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
// Sync input when another device changes the status.
// Skipped while the user has unsaved local edits to avoid clobbering
// mid-flight input (e.g. an emoji being inserted), and while presence data has
// not loaded yet (presence === undefined is "no info", NOT a clear).
useEffect(() => {
if (statusDirtyRef.current || !presence) return;
// An offline/invisible presence carries an empty status_msg by design (see
// usePresenceUpdater.setOffline), so ignore it — reading it as a clear would
// wipe the saved status on every invisible toggle.
if (presence.presence === Presence.Offline) return;
const remoteStatus = presence.status ?? '';
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
} else {
// Another device CLEARED the status. Mirror it locally AND drop the stored
// value, otherwise usePresenceUpdater.readStatus() re-sends the stale
// message on the next presence heartbeat.
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
}, [presence, userId]);
const [saveState, saveStatus] = useAsyncCallback(
useCallback(
(msg: string) =>
mx.setPresence({
// Derive presence from the user's chosen setting so writing a status
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: msg,
}),
[mx, presenceStatus, hidePresence],
),
);
const saving = saveState.status === AsyncStatus.Loading;
const handleEmojiSelect = useCallback((unicode: string) => {
statusDirtyRef.current = true;
setStatusMsg((prev) => prev + unicode);
setEmojiAnchor(undefined);
}, []);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
statusDirtyRef.current = true;
setStatusMsg(evt.currentTarget.value);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
statusDirtyRef.current = false;
const msg = statusMsg.trim();
saveStatus(msg).catch(() => undefined);
if (msg) {
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
} else {
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
const ts = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
}
};
const handleClear = () => {
statusDirtyRef.current = false;
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
// Preserve the user's chosen presence when clearing the status message.
mx.setPresence({
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: '',
}).catch(() => undefined);
};
const hasChanges = statusMsg !== (presence?.status ?? '');
return (
<SettingTile
title={
<Text as="span" size="L400">
Status Message
</Text>
}
description={
<Text size="T200" priority="300">
Shown below your name in member lists. Supports emoji.
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column" gap="100">
<Input
name="statusMsgInput"
aria-label="Status message"
value={statusMsg}
onChange={handleChange}
placeholder="What's on your mind?"
variant="Secondary"
radii="300"
readOnly={saving}
maxLength={64}
/>
<Text
size="T200"
style={{
textAlign: 'right',
opacity: statusMsg.length >= 56 ? 1 : 0.45,
color:
statusMsg.length >= 64
? color.Critical.Main
: statusMsg.length >= 56
? color.Warning.Main
: undefined,
}}
>
{statusMsg.length} / 64
</Text>
</Box>
<PopOut
anchor={emojiAnchor}
position="Top"
align="End"
content={
<EmojiBoard
imagePackRooms={[]}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmojiSelect}
requestClose={() => setEmojiAnchor(undefined)}
/>
}
>
<IconButton
type="button"
size="400"
radii="400"
variant="Surface"
fill="Soft"
outlined
aria-label="Insert emoji"
aria-expanded={!!emojiAnchor}
aria-haspopup="dialog"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const rect = evt.currentTarget.getBoundingClientRect();
setEmojiAnchor((prev) => (prev ? undefined : rect));
}}
>
<Icon src={Icons.Smile} size="400" />
</IconButton>
</PopOut>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || saving}
type="submit"
>
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save status server may be rate limiting. Try again.
</Text>
)}
<Box alignItems="Center" gap="200">
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after:
</Text>
<SettingsSelect
value={clearAfter}
options={CLEAR_AFTER_OPTIONS}
onChange={setClearAfter}
aria-label="Auto-clear status after"
/>
</Box>
{(presence?.status || statusMsg) && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
type="button"
onClick={handleClear}
disabled={saving}
>
<Text size="B300">Clear Status</Text>
</Button>
)}
</Box>
</SettingTile>
);
}
const COMMON_TIMEZONES = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Toronto',
'America/Vancouver',
'America/Sao_Paulo',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Africa/Cairo',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Singapore',
'Asia/Tokyo',
'Asia/Shanghai',
'Australia/Sydney',
'Pacific/Auckland',
];
function ProfilePronouns() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const [pronouns, setPronouns] = useState<string>('');
const [savedPronouns, setSavedPronouns] = useState<string>('');
useEffect(() => {
mx.http
.authedRequest<{ 'm.pronouns': string }>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
)
.then((res) => {
const val = res['m.pronouns'] ?? '';
setPronouns(val);
setSavedPronouns(val);
})
.catch(() => {
setPronouns('');
setSavedPronouns('');
});
}, [mx, userId]);
const [saveState, savePronouns] = useAsyncCallback(
useCallback(
(value: string) =>
mx.http
.authedRequest(
Method.Put,
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
undefined,
{ 'm.pronouns': value },
)
.then(() => {
setSavedPronouns(value);
}),
[mx, userId],
),
);
const saving = saveState.status === AsyncStatus.Loading;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
setPronouns(evt.currentTarget.value);
};
const handleReset = () => {
setPronouns(savedPronouns);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
savePronouns(pronouns.trim());
};
const hasChanges = pronouns !== savedPronouns;
return (
<SettingTile
title={
<Text as="span" size="L400">
Pronouns
</Text>
}
description={
<Text size="T200" priority="300">
Shown on your profile. Visible to other users.
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={saving}>
<Box grow="Yes" direction="Column">
<Input
name="pronounsInput"
aria-label="Pronouns"
value={pronouns}
onChange={handleChange}
placeholder="e.g. they/them, she/her"
variant="Secondary"
radii="300"
maxLength={64}
readOnly={saving}
after={
hasChanges &&
!saving && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
aria-label="Reset pronouns"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || saving}
type="submit"
>
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save pronouns. Try again.
</Text>
)}
</Box>
</SettingTile>
);
}
function ProfileTimezone() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const [timezone, setTimezone] = useState<string>('');
const [savedTimezone, setSavedTimezone] = useState<string>('');
useEffect(() => {
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
if (cached?.timezone) {
setTimezone(cached.timezone);
setSavedTimezone(cached.timezone);
}
// Also fetch from server in case account data hasn't synced yet
mx.http
.authedRequest<{ timezone: string }>(
Method.Get,
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
)
.then((res) => {
const val = res.timezone ?? '';
setTimezone(val);
setSavedTimezone(val);
})
.catch(() => {
/* no stored timezone yet */
});
}, [mx, userId]);
const [saveState, saveTimezone] = useAsyncCallback(
useCallback(
(value: string) =>
Promise.all([
// Self-fallback: account data is readable by useExtendedProfile for the
// own user even on servers without extended-profile (m.tz) support.
setAccountData(mx, 'im.lotus.timezone', { timezone: value }),
// Mirror the pronouns write path so OTHER users can read the timezone
// via the m.tz profile field. Best-effort: standard Synapse rejects
// unknown profile fields, so a failure here must not fail the save.
mx.http
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
'm.tz': value,
})
.catch(() => undefined),
]).then(() => {
setSavedTimezone(value);
}),
[mx, userId],
),
);
const saving = saveState.status === AsyncStatus.Loading;
const handleReset = () => {
setTimezone(savedTimezone);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
saveTimezone(timezone);
};
const hasChanges = timezone !== savedTimezone;
return (
<SettingTile
title={
<Text as="span" size="L400">
Timezone
</Text>
}
description={
<Text size="T200" priority="300">
Your local timezone. Visible to other users.
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column">
<SettingsSelect
value={timezone}
options={[
{ value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]}
onChange={setTimezone}
disabled={saving}
aria-label="Timezone"
/>
</Box>
{hasChanges && !saving && (
<IconButton
type="button"
onClick={handleReset}
size="400"
radii="300"
variant="Secondary"
aria-label="Reset timezone"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)}
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || saving}
type="submit"
>
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save timezone. Try again.
</Text>
)}
</Box>
</SettingTile>
);
}
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
<ProfileStatus />
<ProfilePronouns />
<ProfileTimezone />
<ProfileDecoration />
</SequenceCard>
</Box>
);
}