diff --git a/src/app/features/toast/LotusToastContainer.tsx b/src/app/features/toast/LotusToastContainer.tsx index 37cad61df..070d0953e 100644 --- a/src/app/features/toast/LotusToastContainer.tsx +++ b/src/app/features/toast/LotusToastContainer.tsx @@ -222,6 +222,15 @@ export function LotusToastContainer() { const toasts = useAtomValue(toastQueueAtom); const isMobile = useScreenSize() === ScreenSize.Mobile; + const listRef = useRef(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 ( -
+
{toasts.map((toast) => ( ))} diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 851cbbef5..ebd742109 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -27,8 +27,11 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import FocusTrap from 'focus-trap-react'; -import { Unread } from '../../../../types/matrix/room'; -import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; +import { + factoryRoomIdByActivity, + factoryRoomIdByAtoZ, + factoryRoomIdByUnread, +} from '../../../utils/sort'; import { NavButton, NavCategory, @@ -210,17 +213,6 @@ function HomeEmpty() { ); } -const factoryRoomIdByUnread = - (roomToUnread: Map) => - (aId: string, bId: string): number => { - const aUnread = roomToUnread.get(aId); - const bUnread = roomToUnread.get(bId); - const aHas = (aUnread?.total ?? 0) > 0; - const bHas = (bUnread?.total ?? 0) > 0; - if (aHas !== bHas) return aHas ? -1 : 1; - return (bUnread?.total ?? 0) - (aUnread?.total ?? 0); - }; - const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite'); const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority'); @@ -331,7 +323,7 @@ export function Home() { } else if (homeRoomSort === 'alpha') { comparator = factoryRoomIdByAtoZ(mx); } else if (homeRoomSort === 'unread') { - comparator = factoryRoomIdByUnread(roomToUnread); + comparator = factoryRoomIdByUnread(roomToUnread, mx); } else { comparator = factoryRoomIdByActivity(mx); } diff --git a/src/app/state/toast.test.ts b/src/app/state/toast.test.ts index 18e5b8af7..b3999ec48 100644 --- a/src/app/state/toast.test.ts +++ b/src/app/state/toast.test.ts @@ -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'); diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts index d656482ca..1a3a7f3c9 100644 --- a/src/app/state/toast.ts +++ b/src/app/state/toast.ts @@ -46,12 +46,28 @@ export const createErrorToast = ( const baseAtom = atom([]); +// 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( (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); }, ); diff --git a/src/app/utils/sort.test.ts b/src/app/utils/sort.test.ts index 22cb23016..5b6298e06 100644 --- a/src/app/utils/sort.test.ts +++ b/src/app/utils/sort.test.ts @@ -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 = { 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([ + ['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 = { 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([ + ['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 = { a: 'Banana', b: 'apple', c: '#Cherry' }; const mx = { diff --git a/src/app/utils/sort.ts b/src/app/utils/sort.ts index 512c19989..771e65dce 100644 --- a/src/app/utils/sort.ts +++ b/src/app/utils/sort.ts @@ -1,4 +1,5 @@ import { MatrixClient } from 'matrix-js-sdk'; +import { Unread } from '../../types/matrix/room'; export type SortFunc = (a: T, b: T) => number; @@ -42,6 +43,26 @@ export const factoryRoomIdByUnreadCount = return bT - aT; }; +// "Unread First": rooms with unread sort before those without, then by unread +// count desc, then — crucially for the large all-read tail where counts tie — +// by recent activity, so it isn't left in arbitrary order. +export const factoryRoomIdByUnread = ( + roomToUnread: Map, + mx: MatrixClient, +): SortFunc => { + const byActivity = factoryRoomIdByActivity(mx); + return (a, b) => { + const aUnread = roomToUnread.get(a); + const bUnread = roomToUnread.get(b); + const aHas = (aUnread?.total ?? 0) > 0; + const bHas = (bUnread?.total ?? 0) > 0; + if (aHas !== bHas) return aHas ? -1 : 1; + const byCount = (bUnread?.total ?? 0) - (aUnread?.total ?? 0); + if (byCount !== 0) return byCount; + return byActivity(a, b); + }; +}; + export const byTsOldToNew: SortFunc = (a, b) => a - b; export const byOrderKey: SortFunc = (a, b) => {