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 -2
View File
@@ -999,13 +999,15 @@ Themes:
---
### [ ] P5-6 · Channel / Room Emoji Prefix Support
### [x] P5-6 · Channel / Room Emoji Prefix Support
**What:** Render a leading emoji in a room name slightly larger in the sidebar for visual impact (e.g. 🎮 general). Optional: right-click room → "Set channel emoji" shortcut for admins.
**Note:** Matrix room names already support Unicode — this is purely a rendering enhancement.
**[AUDIT REQUIRED]** Confirm upstream Cinny doesn't strip or truncate leading emoji in sidebar room name display. Also confirm emoji in room names works end-to-end on `matrix.lotusguild.org`.
**Complexity:** Low.
**COMPLETED June 2026.** Two sub-features: **(1) Sidebar rendering** — `RoomNavItem.tsx` detects a leading emoji via `/^(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*/u`; if found, renders the emoji in a `<span style={{ fontSize: '1.15em' }}>` inside the `<Text truncate>` so overflow truncation still applies to the whole name. **(2) Emoji picker on room name inputs** — all three room-name inputs now have a 😊 `IconButton` that opens `EmojiBoard` in a `PopOut`; selecting an emoji prepends it to the name. Locations: Create Room dialog (`CreateRoom.tsx`, converted to controlled input), Room Settings name field (`RoomProfile.tsx`, controlled, only shown when `canEditName`), and the "Rename for me…" dialog in `RoomNavItem.tsx` (uncontrolled, prepends to `inputRef.current.value`). Pattern copied from `ProfileStatus` in `Profile.tsx`.
---
### [x] P5-7 · In-App Notification Toast Redesign (TDS mode only)
@@ -1108,12 +1110,14 @@ Themes:
---
### [ ] P5-18 · Status-Based Avatar Border Color
### [x] P5-18 · Status-Based Avatar Border Color
**What:** Colored ring on avatars matching presence: green (online), yellow (idle), red (DND), grey (offline). Subtle 2px CSS box-shadow/border. Applied across all avatar sizes.
**[AUDIT REQUIRED]** Check existing `PresenceBadge` component — this extends that concept to the avatar border. Verify folds Avatar allows border/shadow styling.
**Complexity:** Low-Medium.
**COMPLETED June 2026.** New `PresenceRingAvatar` wrapper component (`src/app/components/presence/PresenceRingAvatar.tsx`) — calls `useUserPresence(userId)` internally, applies `boxShadow: 0 0 0 2px <color>` + `borderRadius: 50%` on a transparent `inline-flex` wrapper div. Ring colors: `color.Success.Main` (online), `color.Warning.Main` (unavailable/idle), `color.Critical.Main` (DND — detected via `presence.status === 'dnd'`), no ring (offline). Applied to: message timeline sender avatars (`Message.tsx`), members drawer member + knock-request avatars (`MembersDrawer.tsx`), @mention autocomplete suggestions (`UserMentionAutocomplete.tsx`), and inbox notification sender avatars (`Notifications.tsx`). Exported via `src/app/components/presence/index.ts`.
---
### [x] P5-19 · Collapsible Long Messages ('Read more')
+5
View File
@@ -138,11 +138,16 @@ Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]`
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
### Room Customization
- **Room emoji prefix**: A leading emoji in a room name (e.g. 🎮 general) renders at 1.15× size in the sidebar for visual impact. Matrix room names already support Unicode — this is purely a rendering enhancement in `RoomNavItem.tsx`. All three room-name inputs (Create Room, Room Settings, "Rename for me…" dialog) now include a 😊 emoji picker button that prepends the selected emoji to the name field.
### Presence
- **Discord-style presence selector**: Clicking your avatar in the bottom-left sidebar opens a popout with five status options — Online (green), Idle (yellow), Do Not Disturb (red, broadcasts `unavailable` with `status_msg: 'dnd'`), Invisible (grey outline, broadcasts `offline`), and Auto (activity-tracking, the original behaviour). The selected status persists across reloads via the settings atom. A colored badge on the avatar reflects the current status at a glance. `usePresenceUpdater` short-circuits immediately for manual modes; full idle-timer and visibility-change logic only runs in Auto mode. Settings also exposed via `src/app/state/settings.ts` (`presenceStatus` field).
- **Custom status message**: Set a short status text (up to 64 characters) with an emoji picker, shown below your display name in member lists and presence displays. Accessible via Settings → Account → Profile. Includes an **auto-clear timer** (options: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days) — after the timer expires, the status is automatically cleared by setting `status_msg: ''` via `mx.setPresence`. A character counter (shown when ≥ 56/64 chars) prevents overflow. Implemented in `src/app/features/settings/account/Profile.tsx`.
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
- **Presence avatar border ring**: A 2px colored `box-shadow` ring on user avatars throughout the app shows presence at a glance — green (online), yellow (idle), red (DND), no ring (offline). Implemented as `PresenceRingAvatar` wrapper component (`src/app/components/presence/PresenceRingAvatar.tsx`). Applied to: message timeline sender avatars, members drawer, @mention autocomplete, and inbox notification senders.
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
- **Extended profile fields (MSC4133)**: Settings → Account → Profile includes Pronouns (`m.pronouns`) and Timezone (`m.tz`) fields, saved via MSC4133 `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`. Both fields are displayed in user profile panels. Implemented via `src/app/hooks/useExtendedProfile.ts`.
- **User local time in profile**: When a user has `m.tz` set, their profile panel shows a clock icon, their current local time, and the timezone abbreviation (e.g. EST, JST). Updates every 60 seconds. Respects the viewer's `hour24Clock` setting. Implemented via `src/app/hooks/useLocalTime.ts`.
@@ -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>
}
>