fix(ux): cap the in-app toast stack; stable "Unread First" room sort
- Toast queue: a burst of notifications appended unboundedly and could cover the viewport. Cap at 5 in the atom writer, dropping the OLDEST non-sticky toast (sticky = action toasts requiring a click, never dropped). The drop scan excludes the just-appended newest (`length - 1` bound) so a fresh toast is never the one eaten when the cap is full of stickies — it stretches instead. Container gains a maxHeight + overflowY safety net and scrolls the newest (bottom) toast into view if the stack ever overflows. +4 unit tests incl. the cap-full-of-stickies boundary. - "Unread First" room sort left the entire read tail (all counts tie at 0) in arbitrary Map order. factoryRoomIdByUnread now breaks ties by recent activity. Relocated from Home.tsx (module-private) to utils/sort.ts (exported, pure) and unit-tested (equal-count and read-tail cases fall back to activity). Bug-hunt findings from LOTUS_TODO. Three review passes: the second caught that the cap could silently drop the newest notification when full of stickies (real bug, untested boundary) — fixed and covered; a third traced the corrected loop. Gate-green (tsc, eslint, prettier, 920 tests, build). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ import {
|
||||
factoryRoomIdByUnreadCount,
|
||||
factoryRoomIdByActivity,
|
||||
factoryRoomIdByAtoZ,
|
||||
factoryRoomIdByUnread,
|
||||
} from './sort';
|
||||
import type { Unread } from '../../types/matrix/room';
|
||||
|
||||
test('byTsOldToNew sorts ascending by timestamp', () => {
|
||||
assert.ok(byTsOldToNew(1, 2) < 0);
|
||||
@@ -45,6 +47,40 @@ test('factoryRoomIdByActivity sorts most-recently-active first', () => {
|
||||
assert.deepEqual(['missing', 'new'].sort(cmp), ['new', 'missing']);
|
||||
});
|
||||
|
||||
test('factoryRoomIdByUnread: unread first, by count, then activity for ties', () => {
|
||||
const ts: Record<string, number> = { a: 100, b: 300, c: 200, d: 400 };
|
||||
const mx = {
|
||||
getRoom: (id: string) => (id in ts ? { getLastActiveTimestamp: () => ts[id] } : null),
|
||||
} as unknown as MatrixClient;
|
||||
const mkUnread = (total: number): Unread => ({ total, highlight: 0, from: null });
|
||||
// a: 2 unread, b: 5 unread, c: read, d: read
|
||||
const roomToUnread = new Map<string, Unread>([
|
||||
['a', mkUnread(2)],
|
||||
['b', mkUnread(5)],
|
||||
['c', mkUnread(0)],
|
||||
['d', mkUnread(0)],
|
||||
]);
|
||||
const cmp = factoryRoomIdByUnread(roomToUnread, mx);
|
||||
// b (5) and a (2) lead by unread; then the read tail c/d breaks by activity
|
||||
// (d @400 more recent than c @200) rather than arbitrary order.
|
||||
assert.deepEqual(['a', 'b', 'c', 'd'].sort(cmp), ['b', 'a', 'd', 'c']);
|
||||
});
|
||||
|
||||
test('factoryRoomIdByUnread: equal unread counts break by activity', () => {
|
||||
const ts: Record<string, number> = { x: 100, y: 500 };
|
||||
const mx = {
|
||||
getRoom: (id: string) => ({ getLastActiveTimestamp: () => ts[id] ?? 0 }),
|
||||
} as unknown as MatrixClient;
|
||||
const mkUnread = (total: number): Unread => ({ total, highlight: 0, from: null });
|
||||
const roomToUnread = new Map<string, Unread>([
|
||||
['x', mkUnread(3)],
|
||||
['y', mkUnread(3)],
|
||||
]);
|
||||
const cmp = factoryRoomIdByUnread(roomToUnread, mx);
|
||||
// Same unread count → y (more recent) before x.
|
||||
assert.deepEqual(['x', 'y'].sort(cmp), ['y', 'x']);
|
||||
});
|
||||
|
||||
test('factoryRoomIdByAtoZ sorts case-insensitively and ignores leading #', () => {
|
||||
const names: Record<string, string> = { a: 'Banana', b: 'apple', c: '#Cherry' };
|
||||
const mx = {
|
||||
|
||||
Reference in New Issue
Block a user