Files
cinny/src/app/utils/sort.ts
T
jaredandClaude Opus 4.8 1963222d1e 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>
2026-07-24 17:22:23 -04:00

81 lines
2.2 KiB
TypeScript

import { MatrixClient } from 'matrix-js-sdk';
import { Unread } from '../../types/matrix/room';
export type SortFunc<T> = (a: T, b: T) => number;
export const factoryRoomIdByActivity =
(mx: MatrixClient): SortFunc<string> =>
(a, b) => {
const room1 = mx.getRoom(a);
const room2 = mx.getRoom(b);
return (
(room2?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER) -
(room1?.getLastActiveTimestamp() ?? Number.MIN_SAFE_INTEGER)
);
};
export const factoryRoomIdByAtoZ =
(mx: MatrixClient): SortFunc<string> =>
(a, b) => {
let aName = mx.getRoom(a)?.name ?? '';
let bName = mx.getRoom(b)?.name ?? '';
// remove "#" from the room name
// To ignore it in sorting
aName = aName.replace(/#/g, '');
bName = bName.replace(/#/g, '');
if (aName.toLowerCase() < bName.toLowerCase()) {
return -1;
}
if (aName.toLowerCase() > bName.toLowerCase()) {
return 1;
}
return 0;
};
export const factoryRoomIdByUnreadCount =
(getUnreadCount: (roomId: string) => number): SortFunc<string> =>
(a, b) => {
const aT = getUnreadCount(a) ?? 0;
const bT = getUnreadCount(b) ?? 0;
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<string, Unread>,
mx: MatrixClient,
): SortFunc<string> => {
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<number> = (a, b) => a - b;
export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
if (!a && !b) {
return 0;
}
if (!b) return -1;
if (!a) return 1;
if (a < b) {
return -1;
}
return 1;
};