b41bfd35c0
Status message: Synapse clears status_msg when a user goes offline/reconnects.
Fix by caching to localStorage and re-sending on setOnline(). The sync
effect no longer overwrites the local value with an empty server event.
Timezone: PUT /profile/{userId}/m.tz is MSC1769 (unstable) and not
supported by standard Synapse — save/load silently fails. Fix by using
Matrix account data (im.lotus.timezone) instead, which is fully
supported. Own profile falls back to account data; other users still
try the m.tz profile endpoint (for federated servers that support it).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
897 lines
27 KiB
TypeScript
897 lines
27 KiB
TypeScript
import React, {
|
|
ChangeEventHandler,
|
|
FormEventHandler,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
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 { SequenceCard } from '../../../components/sequence-card';
|
|
import { SequenceCardStyle } from '../styles.css';
|
|
import { SettingTile } from '../../../components/setting-tile';
|
|
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 { 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 { useUserPresence } from '../../../hooks/useUserPresence';
|
|
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"
|
|
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>
|
|
);
|
|
}
|
|
|
|
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
|
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 [statusMsg, setStatusMsg] = useState<string>(
|
|
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
|
);
|
|
const [clearAfter, setClearAfter] = useState('0');
|
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
|
|
|
// Initialise expiry from localStorage so timer survives page reload
|
|
const [expiryTs, setExpiryTs] = useState<number>(() => {
|
|
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
|
|
return stored ? parseInt(stored, 10) : 0;
|
|
});
|
|
|
|
// Sync input when another device changes the status.
|
|
// Only update if the server actually has a value — ignore empty sync events
|
|
// caused by Synapse clearing status_msg on reconnect.
|
|
useEffect(() => {
|
|
if (presence?.status) {
|
|
setStatusMsg(presence.status);
|
|
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status);
|
|
}
|
|
}, [presence?.status, userId]);
|
|
|
|
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
|
useEffect(() => {
|
|
if (!expiryTs) return undefined;
|
|
const remaining = expiryTs - Date.now();
|
|
const clearStatus = () => {
|
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
|
setExpiryTs(0);
|
|
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
|
};
|
|
if (remaining <= 0) {
|
|
clearStatus();
|
|
return undefined;
|
|
}
|
|
const timer = window.setTimeout(clearStatus, remaining);
|
|
return () => clearTimeout(timer);
|
|
}, [expiryTs, userId, mx]);
|
|
|
|
const [saveState, saveStatus] = useAsyncCallback(
|
|
useCallback(
|
|
(msg: string) =>
|
|
mx.setPresence({
|
|
presence: 'online',
|
|
status_msg: msg,
|
|
}),
|
|
[mx],
|
|
),
|
|
);
|
|
const saving = saveState.status === AsyncStatus.Loading;
|
|
|
|
const handleEmojiSelect = useCallback((unicode: string) => {
|
|
setStatusMsg((prev) => prev + unicode);
|
|
setEmojiAnchor(undefined);
|
|
}, []);
|
|
|
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
|
setStatusMsg(evt.currentTarget.value);
|
|
};
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
evt.preventDefault();
|
|
if (saving) return;
|
|
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) {
|
|
const ts = Date.now() + delayMs;
|
|
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
|
setExpiryTs(ts);
|
|
} else {
|
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
|
setExpiryTs(0);
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setStatusMsg('');
|
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
|
setExpiryTs(0);
|
|
mx.setPresence({ presence: 'online', 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
|
|
? 'var(--tc-critical-normal)'
|
|
: statusMsg.length >= 56
|
|
? 'var(--tc-warning-normal)'
|
|
: 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: 'var(--tc-critical-normal)' }}>
|
|
Failed to save status — server may be rate limiting. Try again.
|
|
</Text>
|
|
)}
|
|
<Box alignItems="Center" gap="200">
|
|
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
|
Auto-clear after:
|
|
</Text>
|
|
<select
|
|
value={clearAfter}
|
|
onChange={(e) => setClearAfter(e.target.value)}
|
|
aria-label="Auto-clear status after"
|
|
style={{
|
|
background: color.SurfaceVariant.Container,
|
|
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
|
borderRadius: config.radii.R300,
|
|
color: color.SurfaceVariant.OnContainer,
|
|
colorScheme: 'dark',
|
|
fontSize: '0.82rem',
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
|
cursor: 'pointer',
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
{CLEAR_AFTER_OPTIONS.map((opt) => (
|
|
<option
|
|
key={opt.value}
|
|
value={opt.value}
|
|
style={{
|
|
background: color.SurfaceVariant.Container,
|
|
color: color.SurfaceVariant.OnContainer,
|
|
}}
|
|
>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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: 'var(--tc-critical-normal)' }}>
|
|
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 = mx.getAccountData('im.lotus.timezone' as any)?.getContent<{ timezone: string }>();
|
|
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) =>
|
|
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
|
|
setSavedTimezone(value);
|
|
}),
|
|
[mx],
|
|
),
|
|
);
|
|
const saving = saveState.status === AsyncStatus.Loading;
|
|
|
|
const handleSelectChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
|
|
setTimezone(evt.currentTarget.value);
|
|
};
|
|
|
|
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">
|
|
<select
|
|
name="timezoneInput"
|
|
aria-label="Timezone"
|
|
value={timezone}
|
|
onChange={handleSelectChange}
|
|
disabled={saving}
|
|
style={{
|
|
background: color.SurfaceVariant.Container,
|
|
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
|
borderRadius: config.radii.R300,
|
|
color: color.SurfaceVariant.OnContainer,
|
|
colorScheme: 'dark',
|
|
fontSize: '0.875rem',
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
width: '100%',
|
|
cursor: 'pointer',
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
<option value="">— select timezone —</option>
|
|
{COMMON_TIMEZONES.map((tz) => (
|
|
<option
|
|
key={tz}
|
|
value={tz}
|
|
style={{
|
|
background: color.SurfaceVariant.Container,
|
|
color: color.SurfaceVariant.OnContainer,
|
|
}}
|
|
>
|
|
{tz}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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: 'var(--tc-critical-normal)' }}>
|
|
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 />
|
|
</SequenceCard>
|
|
</Box>
|
|
);
|
|
}
|