feat(bookmarks): sort & group saved messages by room

The Saved Messages panel showed bookmarks in one fixed order (newest
save first) with no way to reorganize. Add a Newest / Oldest / By-room
segmented sort control to the panel toolbar. In "By room" mode the list
renders collapsible per-room sections, with groups ordered by their most
recently saved message so active rooms float to the top. The chosen sort
persists across panel opens via a localStorage-backed atom.

Ordering and grouping are pure functions in utils/bookmarks.ts
(sortBookmarks, groupBookmarksByRoom) with deterministic eventId
tie-breaks, covered by bookmarks.test.ts (9 tests). No change to the
bookmark data model, account-data schema, useBookmarks, or how bookmarks
are created; search still feeds the sorter/grouper unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:03:28 -04:00
co-authored by Claude Opus 4.8
parent cacefb1f30
commit cb3cd30ab5
4 changed files with 334 additions and 26 deletions
+169 -26
View File
@@ -1,5 +1,7 @@
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import {
Avatar,
Box,
@@ -16,6 +18,11 @@ import {
} from 'folds';
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import {
BookmarkSort,
sortBookmarks,
groupBookmarksByRoom,
} from '../../utils/bookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
@@ -41,6 +48,43 @@ function formatTimeAgo(ts: number): string {
return new Date(ts).toLocaleDateString();
}
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
const bookmarkSortAtom = atomWithStorage<BookmarkSort>(
'cinny_bookmarks_sort_v1',
'newest',
createJSONStorage(() => localStorage),
);
const SORT_OPTIONS: { value: BookmarkSort; label: string }[] = [
{ value: 'newest', label: 'Newest' },
{ value: 'oldest', label: 'Oldest' },
{ value: 'room', label: 'By room' },
];
// Segmented sort button — mirrors MediaGallery's tab styling for house consistency.
function SortButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
aria-pressed={active}
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
type BookmarkItemProps = {
bookmark: Bookmark;
onJump: (roomId: string, eventId: string) => void;
@@ -147,6 +191,56 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
}
type RoomGroupHeaderProps = {
roomId: string;
roomName: string;
count: number;
collapsed: boolean;
onToggle: () => void;
};
// Collapsible section header for the "By room" grouping. Uses the live room name
// / avatar when the room is joined, falling back to the stored snapshot name.
function RoomGroupHeader({ roomId, roomName, count, collapsed, onToggle }: RoomGroupHeaderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId) ?? undefined;
const displayRoomName = room?.name ?? roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
return (
<Button
variant="Secondary"
fill="None"
size="300"
radii="300"
onClick={onToggle}
aria-expanded={!collapsed}
style={{ justifyContent: 'flex-start', padding: config.space.S200 }}
>
<Box grow="Yes" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
<Icon size="100" src={collapsed ? Icons.ChevronRight : Icons.ChevronBottom} />
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
src={avatarUrl}
alt={displayRoomName}
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
/>
</Avatar>
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
</Box>
</Button>
);
}
type BookmarksPanelProps = {
onClose: () => void;
};
@@ -156,6 +250,18 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
const [sort, setSort] = useAtom(bookmarkSortAtom);
// roomIds whose group section is collapsed (only relevant in "By room" mode).
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggleGroup = useCallback((roomId: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(roomId)) next.delete(roomId);
else next.add(roomId);
return next;
});
}, []);
// Escape closes the panel (parity with the app's other overlays/drawers).
useEffect(() => {
@@ -191,6 +297,35 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
bk.roomName.toLowerCase().includes(query),
);
// Live render when the room is joined (useRoomEvent needs a non-null Room);
// otherwise fall back to the stored snapshot for rooms we've left. Shared by
// both the flat (newest/oldest) and grouped (by room) render paths.
const renderItem = useCallback(
(bk: Bookmark) => {
const room = mx.getRoom(bk.roomId);
return room ? (
<LiveBookmarkItem
key={bk.eventId}
room={room}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
) : (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
);
},
[mx, handleJump, removeBookmark],
);
const sortedItems = sort === 'room' ? filtered : sortBookmarks(filtered, sort);
const groups = sort === 'room' ? groupBookmarksByRoom(filtered) : [];
return (
<Box
className={classNames(css.BookmarksPanel, ContainerColor({ variant: 'Background' }))}
@@ -237,11 +372,23 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
}
/>
{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 alignItems="Center" justifyContent="SpaceBetween" gap="200">
<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 as="div" role="group" aria-label="Sort saved messages" gap="100" shrink="No">
{SORT_OPTIONS.map((opt) => (
<SortButton
key={opt.value}
label={opt.label}
active={sort === opt.value}
onClick={() => setSort(opt.value)}
/>
))}
</Box>
</Box>
)}
</Box>
@@ -265,27 +412,23 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => {
// Live render when the room is joined (useRoomEvent needs a non-null Room);
// otherwise fall back to the stored snapshot for rooms we've left.
const room = mx.getRoom(bk.roomId);
return room ? (
<LiveBookmarkItem
key={bk.eventId}
room={room}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
) : (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
);
})}
{sort === 'room'
? groups.map((group) => {
const isCollapsed = collapsed.has(group.roomId);
return (
<Box key={group.roomId} direction="Column" gap="200">
<RoomGroupHeader
roomId={group.roomId}
roomName={group.roomName}
count={group.items.length}
collapsed={isCollapsed}
onToggle={() => toggleGroup(group.roomId)}
/>
{!isCollapsed && group.items.map((bk) => renderItem(bk))}
</Box>
);
})
: sortedItems.map((bk) => renderItem(bk))}
</Box>
)}
</Scroll>