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
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
1069 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|