Files
cinny/src/app/features/toast/LotusToastContainer.tsx
T
jared 950b8a8128 fix(toast): sticky toasts + improve update notification visibility (P5-40)
Add sticky?: boolean to ToastNotif — sticky toasts skip the 4s auto-dismiss
timer entirely, staying until the user clicks or manually dismisses. Sticky
toasts also use cyan accent/glow (vs orange for messages) and allow the body
to wrap rather than truncate, so longer action-oriented copy is fully readable.

Update the Tauri update toast to: sticky: true, ⬆ prefix on the title,
"Click to install and restart" as explicit call to action.

Fixes: auto-dismiss before user noticed it, no visual distinction from
a regular message notification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 21:04:49 -04:00

216 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
// 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);
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 cardStyle: CSSProperties = {
position: 'relative',
background: 'var(--lt-bg-card)',
border: toast.sticky
? '1px solid var(--lt-accent-cyan-border)'
: '1px solid var(--lt-border-color)',
borderRadius: '12px',
padding: '12px 14px',
minWidth: '280px',
maxWidth: '340px',
boxShadow: toast.sticky ? 'var(--lt-box-glow-cyan)' : 'var(--lt-box-glow-orange)',
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
};
const rowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginRight: '20px',
};
const avatarStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
};
const initialsStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--lt-accent-orange-dim)',
border: '1px solid var(--lt-accent-orange-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
color: 'var(--lt-accent-orange)',
flexShrink: 0,
};
const nameStyle: CSSProperties = {
color: toast.sticky ? 'var(--lt-accent-cyan)' : 'var(--lt-accent-orange)',
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const dismissBtnStyle: CSSProperties = {
position: 'absolute',
top: '8px',
right: '10px',
background: 'none',
border: 'none',
color: 'var(--lt-text-secondary)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
padding: '2px 4px',
borderRadius: '4px',
};
const bodyStyle: CSSProperties = {
color: 'var(--lt-text-primary)',
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
...(toast.sticky
? { whiteSpace: 'normal', lineHeight: 1.4 }
: { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
};
const roomNameStyle: CSSProperties = {
color: 'var(--lt-text-secondary)',
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={`Notification from ${toast.displayName} in ${toast.roomName}`}
>
<button
type="button"
style={dismissBtnStyle}
onClick={handleDismiss}
aria-label="Dismiss notification"
>
×
</button>
<div style={rowStyle}>
{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>
<div style={roomNameStyle}>{toast.roomName}</div>
</div>
);
}
export function LotusToastContainer() {
useEffect(() => {
ensureKeyframes();
}, []);
const toasts = useAtomValue(toastQueueAtom);
if (toasts.length === 0) return null;
const containerStyle: CSSProperties = {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 10001,
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'auto',
};
return (
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} />
))}
</div>
);
}