950b8a8128
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>
216 lines
5.4 KiB
TypeScript
216 lines
5.4 KiB
TypeScript
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>
|
||
);
|
||
}
|