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:
@@ -222,6 +222,15 @@ export function LotusToastContainer() {
|
||||
|
||||
const toasts = useAtomValue(toastQueueAtom);
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The newest toast is the last (bottom) child; if the stack ever overflows its
|
||||
// max-height (many sticky action toasts), keep that newest one in view instead
|
||||
// of leaving it scrolled below the fold.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [toasts.length]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
@@ -237,10 +246,14 @@ export function LotusToastContainer() {
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
pointerEvents: 'auto',
|
||||
// Safety net beyond the queue cap: if many sticky action toasts pile up they
|
||||
// scroll within a bounded height instead of covering the whole screen.
|
||||
maxHeight: isMobile ? '70vh' : '80vh',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||
{toasts.map((toast) => (
|
||||
<ToastCard key={toast.id} toast={toast} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user