feat: presence avatar border rings (P5-18) + room emoji prefix support (P5-6)

P5-18: PresenceRingAvatar wrapper component applies a 2px box-shadow
ring to user avatars — green (online), yellow (idle/unavailable), red
(DND via status_msg='dnd'), no ring (offline). Applied to: message
timeline sender avatars, members drawer (members + knock requests),
@mention autocomplete, and inbox notification senders.

P5-6: Leading emoji in room names renders at 1.15× in the sidebar via
Unicode emoji regex detection in RoomNavItem. Emoji picker (EmojiBoard
in PopOut) added to all three room-name inputs: Create Room dialog
(converted to controlled input), Room Settings name field (shown only
when canEditName), and the "Rename for me" local rename dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:56:19 -04:00
parent 4876c2e4ca
commit c5fbc20394
11 changed files with 326 additions and 103 deletions
@@ -6,13 +6,17 @@ import {
color,
config,
Icon,
IconButton,
Icons,
Input,
PopOut,
RectCords,
Spinner,
Text,
TextArea,
} from 'folds';
import React, { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { EmojiBoard } from '../../../components/emoji-board';
import { useAtomValue } from 'jotai';
import Linkify from 'linkify-react';
import classNames from 'classnames';
@@ -113,6 +117,14 @@ export function RoomProfileEdit({
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined)
: undefined;
const [nameValue, setNameValue] = useState(name);
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => {
setNameValue((prev) => unicode + prev);
setEmojiAnchor(undefined);
}, []);
const topicRef = useRef<HTMLTextAreaElement>(null);
const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile);
@@ -159,11 +171,10 @@ export function RoomProfileEdit({
if (uploadingAvatar) return;
const target = evt.target as HTMLFormElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
if (!nameInput || !topicTextArea) return;
if (!topicTextArea) return;
const roomName = nameInput.value.trim();
const roomName = nameValue.trim();
const roomTopic = topicTextArea.value.trim();
if (roomAvatar === avatar && roomName === name && roomTopic === topic) {
@@ -256,13 +267,52 @@ export function RoomProfileEdit({
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Name</Text>
<Input
name="nameInput"
defaultValue={name}
variant="Secondary"
radii="300"
readOnly={!canEditName || submitting}
/>
<Box direction="Row" gap="100" alignItems="Center">
{canEditName && !submitting && (
<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>
)}
<Box grow="Yes">
<Input
name="nameInput"
value={nameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
variant="Secondary"
radii="300"
readOnly={!canEditName || submitting}
style={{ width: '100%' }}
/>
</Box>
</Box>
</Box>
<Box direction="Inherit" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween">