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:
@@ -727,6 +727,8 @@ Redacted events display "This message has been deleted" along with the redaction
|
||||
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
|
||||
- Maximum of 500 bookmarked entries
|
||||
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
|
||||
- Live-renders edits/redactions, text search, jump-to-message, and remove
|
||||
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
|
||||
- Hook: `src/app/hooks/useBookmarks.ts`
|
||||
|
||||
### Message Scheduling
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sortBookmarks, groupBookmarksByRoom } from './bookmarks';
|
||||
import { Bookmark } from '../hooks/useBookmarks';
|
||||
|
||||
const bk = (eventId: string, roomId: string, savedAt: number, roomName = roomId): Bookmark => ({
|
||||
eventId,
|
||||
roomId,
|
||||
savedAt,
|
||||
roomName,
|
||||
previewText: eventId,
|
||||
});
|
||||
|
||||
test('sortBookmarks newest orders by savedAt descending', () => {
|
||||
const input = [bk('a', 'r1', 100), bk('b', 'r1', 300), bk('c', 'r1', 200)];
|
||||
const out = sortBookmarks(input, 'newest');
|
||||
assert.deepEqual(
|
||||
out.map((b) => b.eventId),
|
||||
['b', 'c', 'a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sortBookmarks oldest orders by savedAt ascending', () => {
|
||||
const input = [bk('a', 'r1', 100), bk('b', 'r1', 300), bk('c', 'r1', 200)];
|
||||
const out = sortBookmarks(input, 'oldest');
|
||||
assert.deepEqual(
|
||||
out.map((b) => b.eventId),
|
||||
['a', 'c', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sortBookmarks room falls back to newest for a flat list', () => {
|
||||
const input = [bk('a', 'r1', 100), bk('b', 'r2', 300)];
|
||||
const out = sortBookmarks(input, 'room');
|
||||
assert.deepEqual(
|
||||
out.map((b) => b.eventId),
|
||||
['b', 'a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sortBookmarks breaks savedAt ties deterministically by eventId', () => {
|
||||
const input = [bk('z', 'r1', 100), bk('a', 'r1', 100), bk('m', 'r1', 100)];
|
||||
const newest = sortBookmarks(input, 'newest');
|
||||
const oldest = sortBookmarks(input, 'oldest');
|
||||
// Equal timestamps → ascending eventId in both directions.
|
||||
assert.deepEqual(
|
||||
newest.map((b) => b.eventId),
|
||||
['a', 'm', 'z'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
oldest.map((b) => b.eventId),
|
||||
['a', 'm', 'z'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sortBookmarks does not mutate its input', () => {
|
||||
const input = [bk('a', 'r1', 100), bk('b', 'r1', 300)];
|
||||
const before = input.map((b) => b.eventId);
|
||||
sortBookmarks(input, 'oldest');
|
||||
assert.deepEqual(
|
||||
input.map((b) => b.eventId),
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
test('groupBookmarksByRoom buckets by room, newest-first within a group', () => {
|
||||
const input = [
|
||||
bk('a', 'r1', 100),
|
||||
bk('b', 'r2', 500),
|
||||
bk('c', 'r1', 300),
|
||||
bk('d', 'r2', 200),
|
||||
];
|
||||
const groups = groupBookmarksByRoom(input);
|
||||
assert.equal(groups.length, 2);
|
||||
const r1 = groups.find((g) => g.roomId === 'r1')!;
|
||||
const r2 = groups.find((g) => g.roomId === 'r2')!;
|
||||
assert.deepEqual(
|
||||
r1.items.map((b) => b.eventId),
|
||||
['c', 'a'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
r2.items.map((b) => b.eventId),
|
||||
['b', 'd'],
|
||||
);
|
||||
});
|
||||
|
||||
test('groupBookmarksByRoom orders groups by most-recent save (active rooms first)', () => {
|
||||
const input = [bk('a', 'r1', 100), bk('c', 'r1', 300), bk('b', 'r2', 500)];
|
||||
// r2's newest is 500, r1's newest is 300 → r2 first.
|
||||
const groups = groupBookmarksByRoom(input);
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.roomId),
|
||||
['r2', 'r1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('groupBookmarksByRoom carries the stored room name', () => {
|
||||
const groups = groupBookmarksByRoom([bk('a', 'r1', 100, 'General')]);
|
||||
assert.equal(groups[0].roomName, 'General');
|
||||
});
|
||||
|
||||
test('groupBookmarksByRoom returns empty for empty input', () => {
|
||||
assert.deepEqual(groupBookmarksByRoom([]), []);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Bookmark } from '../hooks/useBookmarks';
|
||||
|
||||
export type BookmarkSort = 'newest' | 'oldest' | 'room';
|
||||
|
||||
export type BookmarkRoomGroup = {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
items: Bookmark[];
|
||||
};
|
||||
|
||||
// Deterministic newest-first comparator: most recently saved first, ties broken
|
||||
// by eventId so the order is stable regardless of input order (keeps tests and
|
||||
// re-renders from shuffling equal-timestamp entries).
|
||||
const byNewest = (a: Bookmark, b: Bookmark): number =>
|
||||
b.savedAt - a.savedAt || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0);
|
||||
|
||||
/**
|
||||
* Return a new array of bookmarks ordered per the selected sort. `room` has no
|
||||
* flat ordering of its own (grouping is a render concern) and falls back to
|
||||
* newest-first so callers can still render a sensible flat list if they want.
|
||||
*/
|
||||
export function sortBookmarks(bookmarks: Bookmark[], sort: BookmarkSort): Bookmark[] {
|
||||
const copy = [...bookmarks];
|
||||
if (sort === 'oldest') {
|
||||
// Oldest-first is the reverse ordering; keep the same eventId tie-break shape.
|
||||
return copy.sort(
|
||||
(a, b) => a.savedAt - b.savedAt || (a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0),
|
||||
);
|
||||
}
|
||||
return copy.sort(byNewest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket bookmarks by room. Items within a group are newest-first; groups are
|
||||
* ordered by each group's most-recently-saved bookmark (active rooms float up),
|
||||
* with the room's stored name (the panel overrides with the live name when the
|
||||
* room is joined). Deterministic tie-breaks keep the output stable.
|
||||
*/
|
||||
export function groupBookmarksByRoom(bookmarks: Bookmark[]): BookmarkRoomGroup[] {
|
||||
const groups = new Map<string, BookmarkRoomGroup>();
|
||||
bookmarks.forEach((bk) => {
|
||||
const existing = groups.get(bk.roomId);
|
||||
if (existing) {
|
||||
existing.items.push(bk);
|
||||
} else {
|
||||
groups.set(bk.roomId, { roomId: bk.roomId, roomName: bk.roomName, items: [bk] });
|
||||
}
|
||||
});
|
||||
|
||||
const result = [...groups.values()];
|
||||
result.forEach((group) => group.items.sort(byNewest));
|
||||
// Order groups by their newest item's savedAt (desc); tie-break by roomId.
|
||||
result.sort((a, b) => {
|
||||
const diff = b.items[0].savedAt - a.items[0].savedAt;
|
||||
if (diff !== 0) return diff;
|
||||
return a.roomId < b.roomId ? -1 : a.roomId > b.roomId ? 1 : 0;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user