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:
2026-07-24 17:22:23 -04:00
co-authored by Claude Opus 4.8
parent d07f16586a
commit 1963222d1e
6 changed files with 136 additions and 17 deletions
+42 -1
View File
@@ -7,12 +7,13 @@ import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } fro
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
// through a jotai store and read back via toastQueueAtom's getter.
const makeToast = (id: string): ToastNotif => ({
const makeToast = (id: string, sticky?: boolean): ToastNotif => ({
id,
displayName: `name-${id}`,
body: `body-${id}`,
roomName: `room-${id}`,
roomId: `!${id}:server`,
...(sticky ? { sticky: true } : {}),
});
test('starts empty', () => {
@@ -86,6 +87,46 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
);
});
test('toastQueueAtom caps at 5, dropping the oldest non-sticky', () => {
const store = createStore();
// Append 7 transient toasts; the queue should keep only the newest 5.
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['t2', 't3', 't4', 't5', 't6'],
);
});
test('toastQueueAtom never drops a sticky toast, even over cap', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('sticky-old', true));
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
const ids = store.get(toastQueueAtom).map((t) => t.id);
// The sticky action toast survives; the oldest non-sticky ones are dropped.
assert.ok(ids.includes('sticky-old'));
assert.ok(ids.includes('t6')); // newest kept
assert.ok(!ids.includes('t0')); // oldest non-sticky dropped
assert.equal(ids.length, 5);
});
test('toastQueueAtom queue of all-sticky toasts is allowed to exceed the cap', () => {
const store = createStore();
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
// Nothing droppable → all 7 retained rather than silently losing action toasts.
assert.equal(store.get(toastQueueAtom).length, 7);
});
test('toastQueueAtom keeps a new transient toast even when the cap is full of stickies', () => {
const store = createStore();
// Fill the cap with sticky action toasts, then a normal message toast arrives.
for (let i = 0; i < 5; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
store.set(toastQueueAtom, makeToast('fresh'));
const ids = store.get(toastQueueAtom).map((t) => t.id);
// The newest is never the one dropped — the queue stretches instead of eating it.
assert.ok(ids.includes('fresh'));
assert.equal(ids.length, 6);
});
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
const a = createDownloadToast('photo.jpg');
assert.equal(a.displayName, 'Downloaded');
+17 -1
View File
@@ -46,12 +46,28 @@ export const createErrorToast = (
const baseAtom = atom<ToastNotif[]>([]);
// Cap concurrent toasts so a burst (e.g. many rooms lighting up while focused)
// can't stack unbounded and cover the viewport.
const MAX_TOASTS = 5;
// Write-only setter used in ClientNonUIFeatures
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
(get) => get(baseAtom),
(get, set, notif) => {
if (notif === null) return; // no-op guard
set(baseAtom, [...get(baseAtom), notif]);
const next = [...get(baseAtom), notif];
// Over cap: drop the oldest NON-sticky toasts (transient message/error
// toasts auto-dismiss anyway); never drop a sticky action toast, which
// requires a click. The `length - 1` bound excludes the just-appended
// newest, so a fresh toast is never the one dropped — if everything older
// is sticky the cap simply stretches rather than eating the new notice.
for (let i = 0; i < next.length - 1 && next.length > MAX_TOASTS; i += 1) {
if (!next[i].sticky) {
next.splice(i, 1);
i -= 1;
}
}
set(baseAtom, next);
},
);