From 85286adf7bb46c403c6d916a01a87911a7335a1b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 4 Jun 2026 21:56:19 -0400 Subject: [PATCH] feat: presence avatar border rings (P5-18) + room emoji prefix support (P5-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_TODO.md | 8 +- README.md | 5 ++ .../autocomplete/UserMentionAutocomplete.tsx | 33 ++++---- .../presence/PresenceRingAvatar.tsx | 36 ++++++++ src/app/components/presence/index.ts | 1 + .../common-settings/general/RoomProfile.tsx | 70 +++++++++++++--- src/app/features/create-room/CreateRoom.tsx | 72 +++++++++++++--- src/app/features/room-nav/RoomNavItem.tsx | 84 ++++++++++++++++--- src/app/features/room/MembersDrawer.tsx | 38 +++++---- src/app/features/room/message/Message.tsx | 41 ++++----- src/app/pages/client/inbox/Notifications.tsx | 41 ++++----- 11 files changed, 326 insertions(+), 103 deletions(-) create mode 100644 src/app/components/presence/PresenceRingAvatar.tsx diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 1ff7a57d0..86853910c 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -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 `` inside the `` 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 ` + `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') diff --git a/README.md b/README.md index b31892ab5..abba8b41b 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index db33fc561..dcbc6537d 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -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={ - - } - /> - + + + } + /> + + } > @@ -174,14 +177,16 @@ export function UserMentionAutocomplete({ } before={ - - } - /> - + + + } + /> + + } > diff --git a/src/app/components/presence/PresenceRingAvatar.tsx b/src/app/components/presence/PresenceRingAvatar.tsx new file mode 100644 index 000000000..f18a90406 --- /dev/null +++ b/src/app/components/presence/PresenceRingAvatar.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/src/app/components/presence/index.ts b/src/app/components/presence/index.ts index 88fcdf78b..1aeab7e03 100644 --- a/src/app/components/presence/index.ts +++ b/src/app/components/presence/index.ts @@ -1 +1,2 @@ export * from './Presence'; +export * from './PresenceRingAvatar'; diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index acaceb1f4..0f6b30389 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -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(); + + const handleEmojiSelect = useCallback((unicode: string) => { + setNameValue((prev) => unicode + prev); + setEmojiAnchor(undefined); + }, []); + const topicRef = useRef(null); const [imageFile, setImageFile] = useState(); 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({ Name - + + {canEditName && !submitting && ( + setEmojiAnchor(undefined)} + /> + } + > + ) => { + const rect = evt.currentTarget.getBoundingClientRect(); + setEmojiAnchor((prev) => (prev ? undefined : rect)); + }} + > + + + + )} + + ) => setNameValue(e.target.value)} + variant="Secondary" + radii="300" + readOnly={!canEditName || submitting} + style={{ width: '100%' }} + /> + + diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx index 35aed0645..f844ae799 100644 --- a/src/app/features/create-room/CreateRoom.tsx +++ b/src/app/features/create-room/CreateRoom.tsx @@ -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(); + + 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({ Name - } - name="nameInput" - autoFocus - size="500" - variant="SurfaceVariant" - radii="400" - autoComplete="off" - disabled={disabled} - /> + + setEmojiAnchor(undefined)} + /> + } + > + ) => { + const rect = evt.currentTarget.getBoundingClientRect(); + setEmojiAnchor((prev) => (prev ? undefined : rect)); + }} + > + + + + + } + name="nameInput" + autoFocus + size="500" + variant="SurfaceVariant" + radii="400" + autoComplete="off" + disabled={disabled} + value={nameValue} + onChange={(e: React.ChangeEvent) => setNameValue(e.target.value)} + style={{ width: '100%' }} + /> + + Topic (Optional) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 45d1122ad..b4c02d506 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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(null); + const [emojiAnchor, setEmojiAnchor] = useState(); + + 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) { Custom name - + + setEmojiAnchor(undefined)} + /> + } + > + ) => { + const rect = evt.currentTarget.getBoundingClientRect(); + setEmojiAnchor((prev) => (prev ? undefined : rect)); + }} + > + + + + + + + Only visible to you. Leave blank to use the original name. @@ -674,7 +720,23 @@ function RoomNavItem_({ - {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 && ( + + {emojiPrefix.trim()} + + )} + {emojiPrefix ? ` ${nameRest}` : roomName} + + ); + })()} {hasLocalName && ( - } - /> - + + + } + /> + + } after={ <> @@ -440,14 +442,16 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { gap="200" style={{ padding: `0 ${config.space.S200}` }} > - - } - /> - + + + } + /> + + {knockName} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 568c3a067..4e49a6f0b 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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( - - } - /> - + + + } + /> + + ); diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 0e7b7f1d8..bdf651de0 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -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({ - - } - /> - + + + } + /> + + } >