- 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>
80 lines
2.7 KiB
TypeScript
80 lines
2.7 KiB
TypeScript
import { atom } from 'jotai';
|
|
import type { IconSrc } from 'folds';
|
|
|
|
export type ToastNotif = {
|
|
id: string;
|
|
avatarUrl?: string;
|
|
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
|
|
displayName: string;
|
|
body: string;
|
|
roomName: string;
|
|
roomId: string;
|
|
hashPath?: string; // overrides window.location.hash navigation when set
|
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
|
};
|
|
|
|
// Build a "download complete" system toast. Kept folds-free here (the icon src is
|
|
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
|
|
// set so a click only dismisses (never navigates to a room).
|
|
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
|
|
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
displayName: 'Downloaded',
|
|
body: filename,
|
|
roomName: '',
|
|
roomId: '',
|
|
iconSrc,
|
|
onClick: () => undefined,
|
|
});
|
|
|
|
// Build an error/system toast (e.g. a failed command or account action). Mirrors
|
|
// createDownloadToast: folds-free (icon src passed in), empty roomId + no-op
|
|
// onClick so a click only dismisses (never navigates to a room).
|
|
export const createErrorToast = (
|
|
body: string,
|
|
iconSrc?: IconSrc,
|
|
displayName = 'Something went wrong',
|
|
): ToastNotif => ({
|
|
id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
displayName,
|
|
body,
|
|
roomName: '',
|
|
roomId: '',
|
|
iconSrc,
|
|
onClick: () => undefined,
|
|
});
|
|
|
|
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
|
|
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);
|
|
},
|
|
);
|
|
|
|
export const dismissToastAtom = atom<null, [string], void>(null, (get, set, id) =>
|
|
set(
|
|
baseAtom,
|
|
get(baseAtom).filter((t) => t.id !== id),
|
|
),
|
|
);
|