From cb3cd30ab59a73103f089705a902fd8d55210dd7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 9 Jul 2026 23:03:28 -0400 Subject: [PATCH] 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 --- LOTUS_FEATURES.md | 2 + src/app/features/bookmarks/BookmarksPanel.tsx | 195 +++++++++++++++--- src/app/utils/bookmarks.test.ts | 104 ++++++++++ src/app/utils/bookmarks.ts | 59 ++++++ 4 files changed, 334 insertions(+), 26 deletions(-) create mode 100644 src/app/utils/bookmarks.test.ts create mode 100644 src/app/utils/bookmarks.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 85d75d845..35442c273 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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 diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx index 05b56c11e..06206ae54 100644 --- a/src/app/features/bookmarks/BookmarksPanel.tsx +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -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( + '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 ( + + ); +} + type BookmarkItemProps = { bookmark: Bookmark; onJump: (roomId: string, eventId: string) => void; @@ -147,6 +191,56 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem return ; } +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 ( + + ); +} + 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>(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 ? ( + + ) : ( + + ); + }, + [mx, handleJump, removeBookmark], + ); + + const sortedItems = sort === 'room' ? filtered : sortBookmarks(filtered, sort); + const groups = sort === 'room' ? groupBookmarksByRoom(filtered) : []; + return ( {bookmarks.length > 0 && ( - - {filtered.length === bookmarks.length - ? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}` - : `${filtered.length} of ${bookmarks.length} messages`} - + + + {filtered.length === bookmarks.length + ? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}` + : `${filtered.length} of ${bookmarks.length} messages`} + + + {SORT_OPTIONS.map((opt) => ( + setSort(opt.value)} + /> + ))} + + )} @@ -265,27 +412,23 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) { ) : ( - {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 ? ( - - ) : ( - - ); - })} + {sort === 'room' + ? groups.map((group) => { + const isCollapsed = collapsed.has(group.roomId); + return ( + + toggleGroup(group.roomId)} + /> + {!isCollapsed && group.items.map((bk) => renderItem(bk))} + + ); + }) + : sortedItems.map((bk) => renderItem(bk))} )} diff --git a/src/app/utils/bookmarks.test.ts b/src/app/utils/bookmarks.test.ts new file mode 100644 index 000000000..7bc7972c4 --- /dev/null +++ b/src/app/utils/bookmarks.test.ts @@ -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([]), []); +}); diff --git a/src/app/utils/bookmarks.ts b/src/app/utils/bookmarks.ts new file mode 100644 index 000000000..0eb309761 --- /dev/null +++ b/src/app/utils/bookmarks.ts @@ -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(); + 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; +}