f3023b34c8
CI / Build & Quality Checks (push) Successful in 10m12s
Shows X/64 below the input. Fades in at 56 chars (warning colour) and turns critical red at the limit so users always know where they stand. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
576 lines
18 KiB
TypeScript
576 lines
18 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,
|
|
Spinner,
|
|
PopOut,
|
|
RectCords,
|
|
} from 'folds';
|
|
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 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 ?? '');
|
|
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
|
|
useEffect(() => {
|
|
setStatusMsg(presence?.status ?? '');
|
|
}, [presence?.status]);
|
|
|
|
// 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_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);
|
|
|
|
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_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>
|
|
<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: 'var(--bg-surface-variant)',
|
|
border: `1px solid var(--border-surface-variant)`,
|
|
borderRadius: config.radii.R300,
|
|
color: 'inherit',
|
|
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}>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
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 />
|
|
</SequenceCard>
|
|
</Box>
|
|
);
|
|
}
|