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
+73 -11
View File
@@ -73,6 +73,7 @@ import { livekitSupport } from '../../hooks/useLivekitSupport';
import { MessageEvent, StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
import { EmojiBoard } from '../../components/emoji-board';
dayjs.extend(isToday);
dayjs.extend(isYesterday);
@@ -103,6 +104,15 @@ type RenameRoomDialogProps = {
function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const mx = useMatrixClient();
const inputRef = useRef<HTMLInputElement>(null);
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => {
if (inputRef.current) {
inputRef.current.value = unicode + inputRef.current.value;
inputRef.current.focus();
}
setEmojiAnchor(undefined);
}, []);
const getCurrentLocalName = useCallback((): string => {
const content = getLocalRoomNamesContent(mx);
@@ -171,16 +181,52 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="L400">Custom name</Text>
<Input
ref={inputRef}
defaultValue={getCurrentLocalName()}
placeholder={room.name}
variant="Secondary"
radii="300"
maxLength={255}
onKeyDown={handleKeyDown}
autoFocus
/>
<Box direction="Row" gap="100" alignItems="Center">
<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
ref={inputRef}
defaultValue={getCurrentLocalName()}
placeholder={room.name}
variant="Secondary"
radii="300"
maxLength={255}
onKeyDown={handleKeyDown}
autoFocus
style={{ width: '100%' }}
/>
</Box>
</Box>
<Text size="T300" priority="300">
Only visible to you. Leave blank to use the original name.
</Text>
@@ -674,7 +720,23 @@ function RoomNavItem_({
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
{(() => {
const emojiMatch = roomName.match(
/^(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*/u,
);
const emojiPrefix = emojiMatch?.[0] ?? '';
const nameRest = emojiPrefix ? roomName.slice(emojiPrefix.length) : roomName;
return (
<>
{emojiPrefix && (
<span style={{ fontSize: '1.15em', lineHeight: 1 }}>
{emojiPrefix.trim()}
</span>
)}
{emojiPrefix ? ` ${nameRest}` : roomName}
</>
);
})()}
</Text>
{hasLocalName && (
<Icon