- 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>
263 lines
8.1 KiB
TypeScript
263 lines
8.1 KiB
TypeScript
import React, { useEffect, useRef, CSSProperties } from 'react';
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
import { color, config, Icon, IconButton, Icons } from 'folds';
|
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
|
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { zIndices } from '../../styles/zIndex';
|
|
|
|
// Inject the keyframe animation once
|
|
const STYLE_ID = 'lotus-toast-keyframes';
|
|
function ensureKeyframes() {
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
const style = document.createElement('style');
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
@keyframes lotusToastIn {
|
|
from { transform: translateX(120%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
@keyframes lotusToastIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
type ToastCardProps = {
|
|
toast: ToastNotif;
|
|
};
|
|
|
|
function ToastCard({ toast }: ToastCardProps) {
|
|
const dismiss = useSetAtom(dismissToastAtom);
|
|
// Lotus Terminal (TDS) gets its bespoke glow/accents; every other theme uses
|
|
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
|
|
// vars only exist while Terminal mode is active).
|
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (toast.sticky) return;
|
|
timerRef.current = setTimeout(() => {
|
|
dismiss(toast.id);
|
|
}, 4000);
|
|
return () => {
|
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
|
};
|
|
}, [dismiss, toast.id, toast.sticky]);
|
|
|
|
const handleCardClick = () => {
|
|
if (toast.onClick) {
|
|
toast.onClick();
|
|
} else {
|
|
// window.location.hash setter auto-prepends '#', so values must not include it
|
|
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
|
|
}
|
|
dismiss(toast.id);
|
|
};
|
|
|
|
const handleDismiss = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
dismiss(toast.id);
|
|
};
|
|
|
|
const accent = toast.sticky ? color.Primary.Main : color.Surface.OnContainer;
|
|
|
|
const cardStyle: CSSProperties = {
|
|
position: 'relative',
|
|
background: lotusTerminal ? 'var(--lt-bg-card)' : color.Surface.Container,
|
|
border: `${config.borderWidth.B300} solid ${
|
|
lotusTerminal
|
|
? toast.sticky
|
|
? 'var(--lt-accent-cyan-border)'
|
|
: 'var(--lt-border-color)'
|
|
: toast.sticky
|
|
? color.Primary.Main
|
|
: color.Surface.ContainerLine
|
|
}`,
|
|
borderRadius: config.radii.R400,
|
|
padding: `${config.space.S300} ${config.space.S400}`,
|
|
// Full-width on phones (the container spans the viewport there); a fixed
|
|
// 280-340px card would otherwise overflow a narrow screen.
|
|
minWidth: isMobile ? 0 : '280px',
|
|
maxWidth: isMobile ? 'none' : '340px',
|
|
width: isMobile ? '100%' : undefined,
|
|
boxShadow: lotusTerminal
|
|
? toast.sticky
|
|
? 'var(--lt-box-glow-cyan)'
|
|
: 'var(--lt-box-glow-orange)'
|
|
: `0 8px 24px ${color.Other.Shadow}`,
|
|
cursor: 'pointer',
|
|
animation: 'lotusToastIn 0.2s ease-out both',
|
|
userSelect: 'none',
|
|
};
|
|
|
|
const rowStyle: CSSProperties = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: config.space.S200,
|
|
marginRight: config.space.S500,
|
|
};
|
|
|
|
const avatarStyle: CSSProperties = {
|
|
width: '24px',
|
|
height: '24px',
|
|
borderRadius: '50%',
|
|
objectFit: 'cover',
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const initialsStyle: CSSProperties = {
|
|
width: '24px',
|
|
height: '24px',
|
|
borderRadius: '50%',
|
|
background: lotusTerminal ? 'var(--lt-accent-orange-dim)' : color.Primary.Container,
|
|
border: `${config.borderWidth.B300} solid ${
|
|
lotusTerminal ? 'var(--lt-accent-orange-border)' : color.Primary.ContainerLine
|
|
}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '10px',
|
|
fontWeight: 700,
|
|
color: lotusTerminal ? 'var(--lt-accent-orange)' : color.Primary.OnContainer,
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const nameStyle: CSSProperties = {
|
|
color: lotusTerminal
|
|
? toast.sticky
|
|
? 'var(--lt-accent-cyan)'
|
|
: 'var(--lt-accent-orange)'
|
|
: accent,
|
|
fontWeight: 600,
|
|
fontSize: '0.85rem',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
};
|
|
|
|
const bodyStyle: CSSProperties = {
|
|
color: lotusTerminal ? 'var(--lt-text-primary)' : color.Surface.OnContainer,
|
|
fontSize: '0.82rem',
|
|
margin: '4px 0 2px',
|
|
overflow: 'hidden',
|
|
...(toast.sticky
|
|
? { whiteSpace: 'normal', lineHeight: 1.4 }
|
|
: { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
|
|
};
|
|
|
|
const roomNameStyle: CSSProperties = {
|
|
color: lotusTerminal ? 'var(--lt-text-secondary)' : color.SurfaceVariant.OnContainer,
|
|
fontSize: '0.75rem',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
};
|
|
|
|
const initials = toast.displayName
|
|
.split(' ')
|
|
.slice(0, 2)
|
|
.map((w) => w[0] ?? '')
|
|
.join('')
|
|
.toUpperCase();
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
style={cardStyle}
|
|
onClick={handleCardClick}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
|
}}
|
|
aria-label={
|
|
toast.roomName
|
|
? `Notification from ${toast.displayName} in ${toast.roomName}`
|
|
: `${toast.displayName}: ${toast.body}`
|
|
}
|
|
>
|
|
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
|
<IconButton
|
|
type="button"
|
|
size="300"
|
|
radii="300"
|
|
variant="Surface"
|
|
fill="None"
|
|
onClick={handleDismiss}
|
|
aria-label="Dismiss notification"
|
|
>
|
|
<Icon size="100" src={Icons.Cross} />
|
|
</IconButton>
|
|
</span>
|
|
<div style={rowStyle}>
|
|
{toast.iconSrc ? (
|
|
<div style={initialsStyle} aria-hidden="true">
|
|
<Icon size="100" src={toast.iconSrc} />
|
|
</div>
|
|
) : toast.avatarUrl ? (
|
|
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
|
) : (
|
|
<div style={initialsStyle} aria-hidden="true">
|
|
{initials || '?'}
|
|
</div>
|
|
)}
|
|
<span style={nameStyle}>{toast.displayName}</span>
|
|
</div>
|
|
<div style={bodyStyle}>{toast.body}</div>
|
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LotusToastContainer() {
|
|
useEffect(() => {
|
|
ensureKeyframes();
|
|
}, []);
|
|
|
|
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;
|
|
|
|
const containerStyle: CSSProperties = {
|
|
position: 'fixed',
|
|
// Span the width just inside the screen edges on a phone (so full-width
|
|
// cards fit); float bottom-right on desktop.
|
|
bottom: isMobile ? config.space.S200 : '1.5rem',
|
|
right: isMobile ? config.space.S200 : '1.5rem',
|
|
left: isMobile ? config.space.S200 : undefined,
|
|
zIndex: zIndices.toast,
|
|
display: 'flex',
|
|
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 ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
|
|
{toasts.map((toast) => (
|
|
<ToastCard key={toast.id} toast={toast} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|