Files
cinny/src/app/features/toast/LotusToastContainer.tsx
T
jared 26f900870b
CI / Build & Quality Checks (push) Successful in 10m28s
CI / Trigger Desktop Build (push) Successful in 6s
feat: MSC4260 Report User, Bug #6 mutual exclusion, TDS toast compliance
- Add ReportUserModal.tsx — category dropdown + reason input, calls
  POST /_matrix/client/v3/users/{userId}/report via mx.http.authedRequest,
  inline success/error feedback, auto-closes 1500ms after success
- Wire Report User button into UserRoomProfile.tsx between UserModeration
  and UserDeviceSessions (hidden for own profile)
- Bug #6: enforce mutual exclusion between chat backgrounds and seasonal
  themes — ChatBgGrid clears seasonal→'off' on non-'none' pick;
  SeasonalBgGrid clears chatBackground→'none' on real theme pick;
  SeasonalEffect guards against legacy persisted state at render time
- TDS: strip all hardcoded hex/rgba fallbacks from LotusToastContainer.tsx
  (var(--lt-bg-card), --lt-accent-orange, --lt-text-primary/secondary,
  --lt-accent-orange-dim/border, --lt-box-glow-orange)
- Mark Bug #6 FIXED, MSC4260 DONE, toast TDS FIXED in LOTUS_BUGS.md and
  LOTUS_TODO.md; note EventReaders + CallControls already compliant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:37:44 -04:00

208 lines
5.1 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(() => {
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id]);
const handleCardClick = () => {
// 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: '1px solid var(--lt-border-color)',
borderRadius: '12px',
padding: '12px 14px',
minWidth: '280px',
maxWidth: '340px',
boxShadow: '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: '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',
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: 9997,
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>
);
}