From 46da4458ff154f3d7527a74622d2fc91ad3096bf Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 27 May 2026 12:39:51 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20custom=20status=20message=20=E2=80=94?= =?UTF-8?q?=20display=20+=20editor=20with=20emoji=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/components/user-profile/UserHero.tsx | 10 +- .../user-profile/UserRoomProfile.tsx | 2 +- src/app/features/room/MembersDrawer.tsx | 7 +- src/app/features/settings/account/Profile.tsx | 170 ++++++++++++++++++ 4 files changed, 186 insertions(+), 3 deletions(-) diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb7485..90faf0545 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -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} + {status && ( + + + {status} + + + )} ); } diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 361d0f978..2157e456e 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -237,7 +237,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {showEncryption && } {userId !== myUserId && ( diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 29ae8f30e..0f7eb70f2 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -167,10 +167,15 @@ function MemberItem({ } > - + {name} + {presence?.status && ( + + {presence.status} + + )} ); diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 9b9ca3da5..fcd7d846b 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -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(presence?.status ?? ''); + const [emojiAnchor, setEmojiAnchor] = useState(); + const inputRef = useRef(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 = (evt) => { + setStatusMsg(evt.currentTarget.value); + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (saving) return; + saveStatus(statusMsg.trim()); + }; + + const handleClear = () => { + setStatusMsg(''); + mx.setPresence({ + presence: 'online', + status_msg: '', + }); + }; + + const hasChanges = statusMsg !== (presence?.status ?? ''); + + return ( + + Status Message + + } + description={ + + Shown below your name in member lists. Supports emoji. + + } + > + + + + }> + setEmojiAnchor(undefined)} + /> + + } + > + ) => + setEmojiAnchor((prev) => + prev ? undefined : evt.currentTarget.getBoundingClientRect(), + ) + } + > + + + + } + /> + + + + {(presence?.status || statusMsg) && ( + + )} + + + ); +} + export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; @@ -326,6 +495,7 @@ export function Profile() { > + );