From cf839e734560f2c4d9a8fbfaa429854329acbac7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 19 Jun 2026 11:21:29 -0400 Subject: [PATCH] fix(ui): avatar-decoration reliability, Saved Messages + Media Gallery redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar decorations: useAvatarDecoration cached ALL profile-field fetch failures as "no decoration" permanently for the session. The member list and timeline mount many avatars at once, so one rate-limited (429) burst would wipe everyone's decoration until a full reload. Now only a genuine 404 (field unset) is cached; transient errors retry on the next mount. Saved Messages panel β€” full redesign to match the canonical MembersDrawer: - co-located BookmarksPanel.css.ts: toRem(266) + max-width:750px full-screen media query, replacing the old position:absolute/zIndex:100 mobile "modal" that had no backdrop or escape - variant="Background" header; room avatars on each item (was a generic hash) - priority tokens replace all raw opacity hacks; 3px borderLeft accent removed - Escape-to-close; multi-line preview is now a proper folds Button (N38) Media Gallery (N12): moved fixed positioning + width into MediaGallery.css.ts using toRem(320) + a full-screen media query; border/header use config tokens; added Escape-to-close on the panel (previously only the lightbox handled it). Presence (SettingsTab / useUserPresence): - N16: wrap presence-dot trigger in TooltipProvider; replace undefined --bg-surface with color.Background.Container - N17: add escapeDeactivates + isKeyForward/isKeyBackward to the FocusTrap - N19: align reader labels (usePresenceLabel) to the setter vocabulary (Online/Idle/Offline) so a chosen status matches the tooltip others see Co-Authored-By: Claude Opus 4.8 --- LOTUS_BUGS.md | 104 +++--- .../features/bookmarks/BookmarksPanel.css.ts | 42 +++ src/app/features/bookmarks/BookmarksPanel.tsx | 301 ++++++++---------- src/app/features/room/MediaGallery.css.ts | 31 ++ src/app/features/room/MediaGallery.tsx | 43 ++- src/app/hooks/useAvatarDecoration.ts | 16 +- src/app/hooks/useUserPresence.ts | 10 +- src/app/pages/client/ClientLayout.tsx | 5 +- src/app/pages/client/sidebar/SettingsTab.tsx | 73 +++-- 9 files changed, 343 insertions(+), 282 deletions(-) create mode 100644 src/app/features/bookmarks/BookmarksPanel.css.ts create mode 100644 src/app/features/room/MediaGallery.css.ts diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md index 5f98420a4..e8c2976be 100644 --- a/LOTUS_BUGS.md +++ b/LOTUS_BUGS.md @@ -307,63 +307,63 @@ This document tracks identified bugs, edge cases, and architectural discrepancie ### 🟠 Moderate β€” Interaction Pattern or Visual Deviations -| # | Area | File | Lines | Issue | Native Pattern | -| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62–137 | Trigger button is raw ` - onRemove(bookmark.eventId)} - aria-label="Remove bookmark" - > - - - - + ); } type BookmarksPanelProps = { onClose: () => void; - isMobile?: boolean; }; -export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) { +export function BookmarksPanel({ onClose }: BookmarksPanelProps) { const { bookmarks, removeBookmark } = useBookmarks(); const { navigateRoom } = useRoomNavigate(); const [filter, setFilter] = useState(''); - const handleJump = (roomId: string, eventId: string) => { - navigateRoom(roomId, eventId); - onClose(); - }; + // Escape closes the panel (parity with the app's other overlays/drawers). + useEffect(() => { + const handleKeyDown = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + stopPropagation(evt); + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const handleJump = useCallback( + (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + onClose(); + }, + [navigateRoom, onClose], + ); const handleFilterChange = (e: ChangeEvent) => { setFilter(e.target.value); }; + const query = filter.trim().toLowerCase(); const filtered: Bookmark[] = - filter.trim().length === 0 + query.length === 0 ? bookmarks - : bookmarks.filter((bk) => { - const q = filter.toLowerCase(); - return bk.previewText.toLowerCase().includes(q) || bk.roomName.toLowerCase().includes(q); - }); + : bookmarks.filter( + (bk) => + bk.previewText.toLowerCase().includes(query) || + bk.roomName.toLowerCase().includes(query), + ); return ( - {/* Header */} -
+
- Saved Messages + + Saved Messages + - - + +
{/* Search */} - + } + before={} after={ filter.length > 0 ? ( setFilter('')} @@ -227,56 +199,47 @@ export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) { ) : undefined } /> - - - {/* Count badge */} - {bookmarks.length > 0 && ( - - + {bookmarks.length > 0 && ( + {filtered.length === bookmarks.length ? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}` : `${filtered.length} of ${bookmarks.length} messages`} - - )} + )} + {/* List */} - - {filtered.length === 0 ? ( - - - - {bookmarks.length === 0 - ? 'No saved messages yet.\nRight-click any message to bookmark it.' - : 'No bookmarks match your search.'} - - - ) : ( - - {filtered.map((bk) => ( - - ))} - - )} - + + + {filtered.length === 0 ? ( + + + + {bookmarks.length === 0 + ? 'No saved messages yet. Right-click any message to save it.' + : 'No saved messages match your search.'} + + + ) : ( + + {filtered.map((bk) => ( + + ))} + + )} + + ); } diff --git a/src/app/features/room/MediaGallery.css.ts b/src/app/features/room/MediaGallery.css.ts new file mode 100644 index 000000000..346b0d55c --- /dev/null +++ b/src/app/features/room/MediaGallery.css.ts @@ -0,0 +1,31 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Right-side drawer that floats over the room view. 320px is wider than the +// 266px member/bookmark drawers because it hosts a media grid; on narrow +// viewports it expands to fill the screen, matching the app's other drawers. +export const MediaGalleryDrawer = style({ + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + width: toRem(320), + zIndex: 500, + overflow: 'hidden', + borderLeftWidth: config.borderWidth.B300, + borderLeftStyle: 'solid', + borderLeftColor: color.Surface.ContainerLine, + '@media': { + '(max-width: 750px)': { + width: '100%', + borderLeftWidth: 0, + }, + }, +}); + +export const MediaGalleryHeader = style({ + flexShrink: 0, + paddingRight: config.space.S200, + paddingLeft: config.space.S300, + borderBottomWidth: config.borderWidth.B300, +}); diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index 2fdee001d..d819dd008 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -15,13 +15,15 @@ import { config, } from 'folds'; import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; +import classNames from 'classnames'; import { useNearViewport } from '../../hooks/useNearViewport'; import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix'; import { ContainerColor } from '../../styles/ContainerColor.css'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { stopPropagation } from '../../utils/keyboard'; +import * as css from './MediaGallery.css'; type GalleryTab = 'image' | 'video' | 'file'; @@ -545,8 +547,6 @@ type MediaGalleryProps = { export function MediaGallery({ room, onClose }: MediaGalleryProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const screenSize = useScreenSizeContext(); - const isMobile = screenSize === ScreenSize.Mobile; const [tab, setTab] = useState('image'); const [loading, setLoading] = useState(false); @@ -561,6 +561,20 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { setLightboxIndex(null); // stale index would open wrong item in new tab's lightboxItems }, []); + // Escape closes the drawer β€” but only when the lightbox isn't open, since the + // lightbox has its own Escape handler that should take precedence. + useEffect(() => { + if (lightboxIndex !== null) return undefined; + const handleKeyDown = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + stopPropagation(evt); + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [lightboxIndex, onClose]); + const msgtype = TAB_MSGTYPES[tab]; const getFilteredEvents = useCallback( @@ -659,30 +673,11 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { return ( <> {/* Header */} -
+
diff --git a/src/app/hooks/useAvatarDecoration.ts b/src/app/hooks/useAvatarDecoration.ts index f2875f549..c0c43cd50 100644 --- a/src/app/hooks/useAvatarDecoration.ts +++ b/src/app/hooks/useAvatarDecoration.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Method } from 'matrix-js-sdk'; +import { MatrixError, Method } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; const PROFILE_FIELD = 'io.lotus.avatar_decoration'; @@ -32,8 +32,18 @@ function fetchDecoration( cache.set(userId, val); return val; }) - .catch(() => { - cache.set(userId, null); + .catch((err: unknown) => { + // A 404 (M_NOT_FOUND) means the field is genuinely unset β†’ cache "no + // decoration". A transient failure (429 rate-limit, 5xx, network) must + // NOT be cached: doing so permanently hides the user's decoration for the + // whole session. This matters most for the member list and timeline, which + // mount many avatars at once and can trip homeserver rate limits β€” a + // single 429 in that burst would otherwise wipe the decoration until a + // full reload. Leaving the cache unset lets the next mount retry. + const status = err instanceof MatrixError ? err.httpStatus : undefined; + if (status === 404) { + cache.set(userId, null); + } return null; }) .finally(() => { diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index d47063b4d..9cf63e362 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -53,10 +53,14 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { export const usePresenceLabel = (): Record => useMemo( + // Keep this vocabulary aligned with the status setter (PresencePicker in + // SettingsTab.tsx): online -> "Online", unavailable -> "Idle". Previously + // these read "Active"/"Busy"/"Away", so a user who set "Idle" showed as + // "Busy" to others. () => ({ - [Presence.Online]: 'Active', - [Presence.Unavailable]: 'Busy', - [Presence.Offline]: 'Away', + [Presence.Online]: 'Online', + [Presence.Unavailable]: 'Idle', + [Presence.Offline]: 'Offline', }), [], ); diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 2f18cc76f..4cb42de31 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -49,10 +49,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) { {screenSize === ScreenSize.Desktop && ( )} - setBookmarksOpen(false)} - isMobile={screenSize !== ScreenSize.Desktop} - /> + setBookmarksOpen(false)} /> )} diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 9413f28b1..67eb6f9d8 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -9,12 +9,15 @@ import { PopOut, RectCords, Text, + Tooltip, + TooltipProvider, color, config, toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; +import { stopPropagation } from '../../../utils/keyboard'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; @@ -82,12 +85,16 @@ function PresencePicker() { initialFocus: false, onDeactivate: closeMenu, clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', }} > - + Set Status @@ -115,33 +122,45 @@ function PresencePicker() { } > {/* Presence dot sits in the bottom-right corner of SidebarItem (which is position:relative) */} - + {(triggerRef) => ( + + )} + ); }