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
@@ -20,6 +20,7 @@ import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
import { PresenceRingAvatar } from '../../presence';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -47,12 +48,14 @@ function UnknownMentionItem({
}
onClick={() => handleAutocomplete(userId, name)}
before={
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
@@ -174,14 +177,16 @@ export function UserMentionAutocomplete({
</Text>
}
before={
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
@@ -0,0 +1,36 @@
import React, { ReactNode } from 'react';
import { color } from 'folds';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
if (!presence || presence === Presence.Offline) return null;
if (presence === Presence.Unavailable) {
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
}
return color.Success.Main;
}
type PresenceRingAvatarProps = {
userId: string;
children: ReactNode;
};
export function PresenceRingAvatar({ userId, children }: PresenceRingAvatarProps) {
const presence = useUserPresence(userId);
const ringColor = presenceRingColor(presence?.presence, presence?.status);
if (!ringColor) return <>{children}</>;
return (
<div
style={{
display: 'inline-flex',
borderRadius: '50%',
boxShadow: `0 0 0 2px ${ringColor}`,
flexShrink: 0,
}}
>
{children}
</div>
);
}
+1
View File
@@ -1 +1,2 @@
export * from './Presence';
export * from './PresenceRingAvatar';
@@ -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">
+61 -11
View File
@@ -7,13 +7,17 @@ import {
color,
config,
Icon,
IconButton,
Icons,
Input,
PopOut,
RectCords,
Spinner,
Switch,
Text,
TextArea,
} from 'folds';
import { EmojiBoard } from '../../components/emoji-board';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import {
@@ -94,6 +98,13 @@ export function CreateRoomForm({
const [encryption, setEncryption] = useState(false);
const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false);
const [nameValue, setNameValue] = useState('');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => {
setNameValue((prev) => unicode + prev);
setEmojiAnchor(undefined);
}, []);
const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
@@ -183,17 +194,56 @@ export function CreateRoomForm({
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateRoomAccessToIcon(access, type)} />}
name="nameInput"
autoFocus
size="500"
variant="SurfaceVariant"
radii="400"
autoComplete="off"
disabled={disabled}
/>
<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
disabled={disabled}
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
required
before={<Icon size="100" src={getCreateRoomAccessToIcon(access, type)} />}
name="nameInput"
autoFocus
size="500"
variant="SurfaceVariant"
radii="400"
autoComplete="off"
disabled={disabled}
value={nameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
style={{ width: '100%' }}
/>
</Box>
</Box>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Topic (Optional)</Text>
+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
+21 -17
View File
@@ -68,7 +68,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge } from '../../components/presence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
type MemberDrawerHeaderProps = {
room: Room;
@@ -150,14 +150,16 @@ function MemberItem({
radii="400"
onClick={onClick}
before={
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<PresenceRingAvatar userId={member.userId}>
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
}
after={
<>
@@ -440,14 +442,16 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
gap="200"
style={{ padding: `0 ${config.space.S200}` }}
>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<PresenceRingAvatar userId={knockMember.userId}>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<Box grow="Yes" direction="Column">
<Text size="T400" truncate>
{knockName}
+22 -19
View File
@@ -82,6 +82,7 @@ import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
// Delivery status indicator for own messages
function DeliveryStatus({
@@ -873,25 +874,27 @@ export const Message = React.memo(
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar
className={css.MessageAvatar}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined)
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
<PresenceRingAvatar userId={senderId}>
<Avatar
className={css.MessageAvatar}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined)
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarBase>
);
+22 -19
View File
@@ -96,6 +96,7 @@ import {
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { PresenceRingAvatar } from '../../../components/presence';
type RoomNotificationsGroup = {
roomId: string;
@@ -478,25 +479,27 @@ function RoomNotificationsGroupComp({
<ModernLayout
before={
<AvatarBase>
<Avatar size="300">
<UserAvatar
userId={event.sender}
src={
senderAvatarMxc
? (mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop',
) ?? undefined)
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
<PresenceRingAvatar userId={event.sender}>
<Avatar size="300">
<UserAvatar
userId={event.sender}
src={
senderAvatarMxc
? (mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop',
) ?? undefined)
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarBase>
}
>