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
+104
View File
@@ -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([]), []);
});
+59
View File
@@ -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;
}