fix(ui): avatar-decoration reliability, Saved Messages + Media Gallery redesign
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 9s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:21:29 -04:00
parent c54cb126ff
commit cf839e7345
9 changed files with 343 additions and 282 deletions
@@ -0,0 +1,42 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const BookmarksHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const BookmarksToolbar = style({
flexShrink: 0,
padding: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
});
export const BookmarksContent = style({
padding: config.space.S200,
});
export const BookmarkPreview = style({
width: '100%',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
textAlign: 'left',
});
+132 -169
View File
@@ -1,5 +1,6 @@
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import {
Avatar,
Box,
Button,
Header,
@@ -12,9 +13,17 @@ import {
color,
config,
} from 'folds';
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './BookmarksPanel.css';
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
@@ -37,187 +46,150 @@ type BookmarkItemProps = {
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
const mx = useMatrixClient();
const room = mx.getRoom(bookmark.roomId);
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
const displayRoomName = room?.name ?? bookmark.roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
return (
<Box
direction="Column"
gap="200"
style={{
padding: `${config.space.S300} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
transition: 'background 0.1s',
padding: config.space.S300,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
{/* Room name row */}
<Box alignItems="Center" gap="100">
<Icon src={Icons.Hash} size="50" style={{ opacity: 0.5, flexShrink: 0 }} />
<Text
size="T200"
style={{
color: color.Primary.Main,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
{/* Room identity + remove */}
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={bookmark.roomId}
src={avatarUrl}
alt={displayRoomName}
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
/>
</Avatar>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
</Text>
</Box>
<IconButton
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove saved message"
>
{displayRoomName}
</Text>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
{/* Message preview */}
<Box
style={{
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
borderLeft: `3px solid ${color.Primary.Main}`,
padding: `${config.space.S100} ${config.space.S200}`,
}}
{/* Message preview — clicking jumps to the message */}
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
aria-label="Jump to saved message"
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text
size="T300"
style={{
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
opacity: 0.9,
}}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'}
</Text>
</Box>
{/* Footer row */}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" style={{ opacity: 0.5 }}>
{formatTimeAgo(bookmark.savedAt)}
</Text>
<Box gap="100" shrink="No">
<Button
size="300"
variant="Primary"
fill="Soft"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
before={<Icon size="100" src={Icons.ArrowRight} />}
>
<Text size="T300">Jump</Text>
</Button>
<IconButton
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove bookmark"
>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
</Box>
</Button>
</Box>
);
}
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<HTMLInputElement>) => {
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 (
<Box
className={classNames(css.BookmarksPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
style={{
width: isMobile ? '100%' : '266px',
height: '100%',
position: isMobile ? 'absolute' : 'static',
top: isMobile ? 0 : 'auto',
left: isMobile ? 0 : 'auto',
zIndex: isMobile ? 100 : 'auto',
flexShrink: 0,
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<Header
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
variant="Surface"
size="600"
>
<Header className={css.BookmarksHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Star} size="200" />
<Box grow="Yes">
<Text size="H5">Saved Messages</Text>
<Text size="H4" truncate>
Saved Messages
</Text>
</Box>
<IconButton
size="300"
variant="Surface"
radii="300"
aria-label="Close saved messages"
onClick={onClose}
>
<Icon src={Icons.Cross} size="100" />
<IconButton size="300" radii="300" aria-label="Close saved messages" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{/* Search */}
<Box
style={{
flexShrink: 0,
padding: config.space.S200,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Box className={css.BookmarksToolbar} direction="Column" gap="100">
<Input
variant="Surface"
variant="SurfaceVariant"
size="400"
radii="400"
radii="300"
placeholder="Search saved messages…"
value={filter}
onChange={handleFilterChange}
before={<Icon size="200" src={Icons.Search} />}
before={<Icon size="100" src={Icons.Search} />}
after={
filter.length > 0 ? (
<IconButton
size="300"
variant="Surface"
variant="SurfaceVariant"
fill="None"
radii="300"
aria-label="Clear search"
onClick={() => setFilter('')}
@@ -227,56 +199,47 @@ export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) {
) : undefined
}
/>
</Box>
{/* Count badge */}
{bookmarks.length > 0 && (
<Box
style={{
flexShrink: 0,
padding: `${config.space.S100} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Text size="T200" style={{ opacity: 0.6 }}>
{bookmarks.length > 0 && (
<Text size="T200" priority="300">
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
</Box>
)}
)}
</Box>
{/* List */}
<Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S600, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: 0.3 }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet.\nRight-click any message to bookmark it.'
: 'No bookmarks match your search.'}
</Text>
</Box>
) : (
<Box direction="Column">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: config.opacity.Disabled }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet. Right-click any message to save it.'
: 'No saved messages match your search.'}
</Text>
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
</Box>
</Box>
);
}
+31
View File
@@ -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,
});
+19 -24
View File
@@ -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<GalleryTab>('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 (
<>
<Box
className={ContainerColor({ variant: 'Surface' })}
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Surface' }))}
direction="Column"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: isMobile ? '100%' : '320px',
zIndex: 500,
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
}}
>
{/* Header */}
<Header
variant="Surface"
size="600"
style={{
flexShrink: 0,
paddingRight: config.space.S200,
paddingLeft: config.space.S300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Header variant="Surface" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
+13 -3
View File
@@ -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(() => {
+7 -3
View File
@@ -53,10 +53,14 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
export const usePresenceLabel = (): Record<Presence, string> =>
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',
}),
[],
);
+1 -4
View File
@@ -49,10 +49,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
</>
)}
</Box>
+46 -27
View File
@@ -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',
}}
>
<Menu variant="Surface" style={{ padding: config.space.S100, minWidth: toRem(210) }}>
<Box direction="Column" gap="100">
<Box style={{ padding: `${config.space.S100} ${config.space.S200}` }}>
<Text size="L400" style={{ opacity: 0.6 }}>
<Text size="L400" priority="300">
Set Status
</Text>
</Box>
@@ -115,33 +122,45 @@ function PresencePicker() {
}
>
{/* Presence dot sits in the bottom-right corner of SidebarItem (which is position:relative) */}
<button
type="button"
aria-label={`Status: ${currentOption.label}. Click to change.`}
onClick={(e) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget.getBoundingClientRect());
}}
style={{
position: 'absolute',
bottom: 2,
right: 2,
background: 'var(--bg-surface)',
border: 'none',
borderRadius: '50%',
padding: 2,
lineHeight: 0,
cursor: 'pointer',
zIndex: 1,
}}
<TooltipProvider
position="Right"
tooltip={
<Tooltip>
<Text size="T200">{`Status: ${currentOption.label}`}</Text>
</Tooltip>
}
>
<Badge
size="200"
variant={presenceVariant(presenceStatus)}
fill={presenceStatus === 'invisible' ? 'Soft' : 'Solid'}
radii="Pill"
/>
</button>
{(triggerRef) => (
<button
type="button"
ref={triggerRef}
aria-label={`Status: ${currentOption.label}. Click to change.`}
onClick={(e) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget.getBoundingClientRect());
}}
style={{
position: 'absolute',
bottom: 2,
right: 2,
background: color.Background.Container,
border: 'none',
borderRadius: '50%',
padding: 2,
lineHeight: 0,
cursor: 'pointer',
zIndex: 1,
}}
>
<Badge
size="200"
variant={presenceVariant(presenceStatus)}
fill={presenceStatus === 'invisible' ? 'Soft' : 'Solid'}
radii="Pill"
/>
</button>
)}
</TooltipProvider>
</PopOut>
);
}