feat: custom status message — display + editor with emoji picker
CI / Build & Quality Checks (push) Successful in 10m23s

- 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:
2026-05-27 12:39:51 -04:00
parent 1c6df604b1
commit c36401db7e
4 changed files with 186 additions and 3 deletions
+9 -1
View File
@@ -95,8 +95,9 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
type UserHeroNameProps = { type UserHeroNameProps = {
displayName?: string; displayName?: string;
userId: string; userId: string;
status?: string;
}; };
export function UserHeroName({ displayName, userId }: UserHeroNameProps) { export function UserHeroName({ displayName, userId, status }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId); const username = getMxIdLocalPart(userId);
return ( return (
@@ -115,6 +116,13 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
@{username} @{username}
</Text> </Text>
</Box> </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> </Box>
); );
} }
@@ -237,7 +237,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}> <Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Box gap="400" alignItems="Center"> <Box gap="400" alignItems="Center">
<UserHeroName displayName={displayName} userId={userId} /> <UserHeroName displayName={displayName} userId={userId} status={presence?.status} />
{showEncryption && <MemberVerificationBadge userId={userId} />} {showEncryption && <MemberVerificationBadge userId={userId} />}
{userId !== myUserId && ( {userId !== myUserId && (
<Box shrink="No"> <Box shrink="No">
+6 -1
View File
@@ -167,10 +167,15 @@ function MemberItem({
</> </>
} }
> >
<Box grow="Yes"> <Box grow="Yes" direction="Column">
<Text size="T400" truncate> <Text size="T400" truncate>
{name} {name}
</Text> </Text>
{presence?.status && (
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{presence.status}
</Text>
)}
</Box> </Box>
</MenuItem> </MenuItem>
); );
@@ -1,9 +1,11 @@
import React, { import React, {
ChangeEventHandler, ChangeEventHandler,
FormEventHandler, FormEventHandler,
Suspense,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react'; } from 'react';
import { import {
@@ -23,6 +25,8 @@ import {
Header, Header,
config, config,
Spinner, Spinner,
PopOut,
RectCords,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@@ -43,6 +47,11 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities'; 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 = { type ProfileProps = {
profile: UserProfile; 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() { export function Profile() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
@@ -326,6 +495,7 @@ export function Profile() {
> >
<ProfileAvatar userId={userId} profile={profile} /> <ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} /> <ProfileDisplayName userId={userId} profile={profile} />
<ProfileStatus />
</SequenceCard> </SequenceCard>
</Box> </Box>
); );