feat: custom status message — display + editor with emoji picker
- MembersDrawer: show presence.status as small muted text below
username in every member row (live via useUserPresence)
- UserHero/UserHeroName: accept optional status prop; render below
the @username handle in user profile popouts
- UserRoomProfile: pass presence?.status down to UserHeroName
- Profile settings: new ProfileStatus tile below Display Name
* Input with inline emoji picker (lazy-loaded EmojiBoard)
* Cursor-aware emoji insertion (preserves caret position)
* Save via mx.setPresence({ status_msg }) / Clear button
* Pre-fills from current presence; syncs on remote update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,8 +95,9 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
type UserHeroNameProps = {
|
||||
displayName?: string;
|
||||
userId: string;
|
||||
status?: string;
|
||||
};
|
||||
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||
export function UserHeroName({ displayName, userId, status }: UserHeroNameProps) {
|
||||
const username = getMxIdLocalPart(userId);
|
||||
|
||||
return (
|
||||
@@ -115,6 +116,13 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||
@{username}
|
||||
</Text>
|
||||
</Box>
|
||||
{status && (
|
||||
<Box alignItems="Center" gap="100" wrap="Wrap" style={{ marginTop: '2px' }}>
|
||||
<Text size="T200" className={classNames(BreakWord, LineClamp3)} style={{ opacity: 0.75 }}>
|
||||
{status}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
||||
<Box direction="Column" gap="400">
|
||||
<Box gap="400" alignItems="Center">
|
||||
<UserHeroName displayName={displayName} userId={userId} />
|
||||
<UserHeroName displayName={displayName} userId={userId} status={presence?.status} />
|
||||
{showEncryption && <MemberVerificationBadge userId={userId} />}
|
||||
{userId !== myUserId && (
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -167,10 +167,15 @@ function MemberItem({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{presence?.status && (
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{presence.status}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
FormEventHandler,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
Header,
|
||||
config,
|
||||
Spinner,
|
||||
PopOut,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -43,6 +47,11 @@ 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';
|
||||
|
||||
const EmojiBoard = React.lazy(() =>
|
||||
import('../../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })),
|
||||
);
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -310,6 +319,166 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileStatus() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const presence = useUserPresence(userId);
|
||||
|
||||
const [statusMsg, setStatusMsg] = useState<string>(presence?.status ?? '');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setStatusMsg(presence?.status ?? '');
|
||||
}, [presence?.status]);
|
||||
|
||||
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) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
const start = input.selectionStart ?? statusMsg.length;
|
||||
const end = input.selectionEnd ?? statusMsg.length;
|
||||
const next = statusMsg.slice(0, start) + unicode + statusMsg.slice(end);
|
||||
setStatusMsg(next);
|
||||
// restore cursor after emoji insertion
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(start + unicode.length, start + unicode.length);
|
||||
});
|
||||
} else {
|
||||
setStatusMsg((prev) => prev + unicode);
|
||||
}
|
||||
setEmojiAnchor(undefined);
|
||||
},
|
||||
[statusMsg],
|
||||
);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setStatusMsg(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
saveStatus(statusMsg.trim());
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setStatusMsg('');
|
||||
mx.setPresence({
|
||||
presence: 'online',
|
||||
status_msg: '',
|
||||
});
|
||||
};
|
||||
|
||||
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" aria-disabled={saving}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
name="statusMsgInput"
|
||||
aria-label="Status message"
|
||||
value={statusMsg}
|
||||
onChange={handleChange}
|
||||
placeholder="What's on your mind?"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
readOnly={saving}
|
||||
after={
|
||||
<PopOut
|
||||
anchor={emojiAnchor}
|
||||
position="Top"
|
||||
align="End"
|
||||
content={
|
||||
<Suspense fallback={<Spinner size="100" />}>
|
||||
<EmojiBoard
|
||||
imagePackRooms={[]}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
requestClose={() => setEmojiAnchor(undefined)}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
aria-label="Insert emoji"
|
||||
aria-expanded={!!emojiAnchor}
|
||||
aria-haspopup="dialog"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setEmojiAnchor((prev) =>
|
||||
prev ? undefined : evt.currentTarget.getBoundingClientRect(),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon src={Icons.Smile} size="100" />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{(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()!;
|
||||
@@ -326,6 +495,7 @@ export function Profile() {
|
||||
>
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
<ProfileStatus />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user