Files
cinny/src/app/features/settings/account/Profile.tsx
T
Lotus CIandClaude Opus 5.5 f62c5d5778
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
fix(status): a status cleared on another device stays cleared (#187 DP3)
Status messages are saved per device and re-sent with every presence
heartbeat (a presence write without status_msg clears it on Synapse). A
device never receives its own user's presence changes made on other
devices, so the DP3 fix in db864326 — mirroring remote changes from the
Profile page — could never fire: device B kept a status that device A had
cleared and re-published it on its next state change.

Heartbeats now reconcile with the server first: GET our own presence,
send the server's current status_msg and bring the local copy in line.
Falls back to the local copy when the read fails, when the server shows
us offline (invisible mode clears the status by design), and for 15 s
after this device saved/cleared its own status (a server read that
hasn't caught up yet can't override a fresh save).

Verified with two sessions of the same user against local Synapse:
B sets "dp3 old status" → A clears it → B goes hidden→visible → server
stays "" and B's local copy is removed (before: back to "dp3 old status").
A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:50:13 -04:00

1069 lines
35 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,
Chip,
} 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 { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
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 { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
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 { noteLocalStatusWrite } from '../../../utils/ownStatusSync';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
import { useStatusPresets } from '../../../hooks/useStatusPresets';
import {
BUILT_IN_STATUS_PRESETS,
StatusPreset,
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
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]);
// [Gitea #109] Avatars go through the same metadata strip as messages.
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const pickFile = useFilePicker(
useCallback(
(file: File) => {
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
setImageFile(r.file),
);
},
[stripImageMetadata],
),
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 initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
// 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);
// The last remote status we synced into the input. Presence heartbeats fire
// every few seconds carrying the SAME status; the sync effect must only react
// when the remote value actually changes, otherwise a repeated heartbeat can
// overwrite an unsaved local edit (e.g. an emoji just inserted) the instant
// the dirty flag is out of sync. Seeded with the value the input started on.
const lastSyncedRemoteRef = useRef<string>(initialStatus);
// The value we most recently applied (with the apply time). A presence
// heartbeat can echo the PREVIOUS status just after we save the new one; that
// stale echo would otherwise revert the input. We ignore non-matching echoes
// until our own echo lands or a short window elapses (bounded so a genuinely
// dropped echo can't block real cross-device updates forever).
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const { presets, addPreset, removePreset } = useStatusPresets();
// 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 ?? '';
// Only act on an actual remote change. Repeated heartbeats carrying the same
// status are ignored so they can never clobber an unsaved local edit.
if (remoteStatus === lastSyncedRemoteRef.current) return;
lastSyncedRemoteRef.current = remoteStatus;
const pending = pendingAppliedRef.current;
if (pending) {
if (remoteStatus === pending.value) {
pendingAppliedRef.current = null; // our own echo landed — accept it
} else if (Date.now() - pending.ts < 15_000) {
return; // stale echo of the previous status; ignore within the window
} else {
pendingAppliedRef.current = null; // window elapsed — accept external changes
}
}
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) =>
// Synapse allows ONE presence write per 10 s per user (shared by all
// devices) and our heartbeat spends that budget too — wait out a 429
// instead of failing the user's save.
setPresenceWithRetry(mx, {
// 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);
};
// Save a status message + auto-clear timer. Shared by the Save button and the
// one-click presets so all three go through exactly the same server write and
// localStorage bookkeeping.
const applyStatus = useCallback(
(rawMsg: string, clearAfterValue: string) => {
statusDirtyRef.current = false;
const msg = rawMsg.trim();
// Guard against a stale presence echo reverting this value (see the sync effect).
pendingAppliedRef.current = { value: msg, ts: Date.now() };
noteLocalStatusWrite();
saveStatus(msg).catch(() => undefined);
if (msg) {
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
} else {
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
const delayMs = getMsFromOption(clearAfterValue);
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
}
},
[saveStatus, userId],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
applyStatus(statusMsg, clearAfter);
};
// Preset click = one-click apply: reflect it in the inputs and save immediately.
const applyPreset = useCallback(
(preset: StatusPreset) => {
if (saving) return;
setStatusMsg(preset.label);
setClearAfter(preset.clearAfter);
applyStatus(preset.label, preset.clearAfter);
},
[saving, applyStatus],
);
const handleSaveCurrent = useCallback(() => {
const label = statusMsg.trim();
if (!label) return;
addPreset({ id: makePresetId(), label, clearAfter }).catch(() => undefined);
}, [statusMsg, clearAfter, addPreset]);
// Hide any saved preset that duplicates a built-in (it already shows under
// "Quick statuses"), so the same status can't appear as two chips.
const customPresets = useMemo(() => {
const builtInLabels = new Set(BUILT_IN_STATUS_PRESETS.map((p) => normalizeLabel(p.label)));
return presets.filter((p) => !builtInLabels.has(normalizeLabel(p.label)));
}, [presets]);
const handleClear = () => {
statusDirtyRef.current = false;
pendingAppliedRef.current = { value: '', ts: Date.now() };
noteLocalStatusWrite();
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="200">
{/* Quick statuses — built-in presets, one click applies message + timer */}
<Box direction="Column" gap="100">
<Text id="status-quick-heading" size="T200" priority="300">
Quick statuses
</Text>
<Box gap="100" wrap="Wrap" role="group" aria-labelledby="status-quick-heading">
{BUILT_IN_STATUS_PRESETS.map((preset) => (
<Chip
key={preset.id}
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
{preset.label}
</Text>
</Chip>
))}
</Box>
</Box>
{/* Your presets — saved from the current status; synced via account data */}
<Box direction="Column" gap="100">
<Text id="status-custom-heading" size="T200" priority="300">
Your presets
</Text>
<Box gap="200" wrap="Wrap" role="group" aria-labelledby="status-custom-heading">
{customPresets.map((preset) => (
// gap="0" keeps the chip and its delete X reading as one unit; the
// parent row's larger gap="200" separates one preset from the next.
<Box key={preset.id} alignItems="Center" gap="0">
<Chip
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
{preset.label}
</Text>
</Chip>
<IconButton
type="button"
size="300"
radii="Pill"
variant="Secondary"
fill="None"
aria-label={`Delete preset ${preset.label}`}
onClick={() => removePreset(preset.id).catch(() => undefined)}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
))}
<Chip
type="button"
variant="Success"
fill="Soft"
radii="Pill"
outlined
disabled={saving || !statusMsg.trim()}
onClick={handleSaveCurrent}
before={<Icon size="100" src={Icons.Plus} />}
>
<Text as="span" size="B300">
Save current
</Text>
</Chip>
</Box>
</Box>
<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}
// A status message is plain-text presence (`status_msg`), so it
// can't hold a custom mxc-image emoji — show unicode only, so every
// emoji in the picker actually inserts (custom ones silently no-op'd
// because there's no onCustomEmojiSelect here).
hideCustomEmojis
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 }}>
{describePresenceError(saveState.error)}
</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>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const pronounsDirtyRef = useRef(false);
useEffect(() => {
let cancelled = false;
mx.http
.authedRequest<{ 'm.pronouns': string }>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
)
.then((res) => {
if (cancelled || pronounsDirtyRef.current) return;
const val = res['m.pronouns'] ?? '';
setPronouns(val);
setSavedPronouns(val);
})
.catch(() => {
if (cancelled || pronounsDirtyRef.current) return;
setPronouns('');
setSavedPronouns('');
});
return () => {
cancelled = true;
};
}, [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) => {
pronounsDirtyRef.current = true;
setPronouns(evt.currentTarget.value);
};
const handleReset = () => {
pronounsDirtyRef.current = true;
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>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const timezoneDirtyRef = useRef(false);
useEffect(() => {
let cancelled = false;
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
if (cached?.timezone && !timezoneDirtyRef.current) {
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) => {
if (cancelled || timezoneDirtyRef.current) return;
const val = res.timezone ?? '';
setTimezone(val);
setSavedTimezone(val);
})
.catch(() => {
/* no stored timezone yet */
});
return () => {
cancelled = true;
};
}, [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 handleChange = (value: string) => {
timezoneDirtyRef.current = true;
setTimezone(value);
};
const handleReset = () => {
timezoneDirtyRef.current = true;
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={handleChange}
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>
);
}