Add notification centre and realtime toasts
This commit is contained in:
1 parent
b400042244
commit
a85b99ceb6
5 files changed
+505
-2
No files matched your search
@@ -2,12 +2,13 @@ import { useState } from 'react';
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { navigationItems } from './navigation.js';
|
import { navigationItems } from './navigation.js';
|
||||||
import { ShellStatusPanel } from './shell-status-panel.js';
|
import { ShellStatusPanel } from './shell-status-panel.js';
|
||||||
|
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<NotificationProvider><div className="app-shell">
|
||||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||||
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
@@ -16,6 +17,7 @@ export function AppShell() {
|
|||||||
<strong>Reddit Bot</strong>
|
<strong>Reddit Bot</strong>
|
||||||
<span>Operator console</span>
|
<span>Operator console</span>
|
||||||
</div>
|
</div>
|
||||||
|
<NotificationBell />
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="Primary navigation" id="primary-navigation">
|
<nav aria-label="Primary navigation" id="primary-navigation">
|
||||||
{navigationItems.map((item) => (
|
{navigationItems.map((item) => (
|
||||||
@@ -45,6 +47,8 @@ export function AppShell() {
|
|||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
<header className="mobile-header">
|
<header className="mobile-header">
|
||||||
<span>Reddit Bot</span>
|
<span>Reddit Bot</span>
|
||||||
|
<div className="mobile-header-actions">
|
||||||
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
aria-controls="primary-navigation"
|
aria-controls="primary-navigation"
|
||||||
aria-expanded={isNavigationOpen}
|
aria-expanded={isNavigationOpen}
|
||||||
@@ -55,11 +59,13 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" tabIndex={-1}>
|
<main id="main-content" tabIndex={-1}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<NotificationViewport />
|
||||||
|
</div></NotificationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.notification-bell {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
color: #dfe9e3;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: .7rem;
|
||||||
|
}
|
||||||
|
.notification-bell:hover { color: var(--accent); border-color: #3d5a4c; }
|
||||||
|
.notification-bell:focus-visible, .notification-close:focus-visible, .notification-clear:focus-visible, .notification-entry:focus-visible, .notification-toast a:focus-visible, .notification-toast button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.notification-bell svg, .notification-empty svg { width: 1.2rem; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
|
||||||
|
.notification-badge { position: absolute; top: -.35rem; right: -.35rem; display: grid; place-items: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .25rem; color: #07130c; background: var(--accent); border: 2px solid #0c1511; border-radius: 999px; font-size: .62rem; font-weight: 900; line-height: 1; }
|
||||||
|
.brand { justify-content: space-between; }
|
||||||
|
.brand > div { margin-right: auto; }
|
||||||
|
.notification-backdrop { position: fixed; inset: 0; z-index: 69; width: 100%; padding: 0; background: rgba(0, 0, 0, .52); border: 0; }
|
||||||
|
.notification-centre { position: fixed; inset: 0 0 0 auto; z-index: 70; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(27rem, 100%); visibility: hidden; background: #0c1511; border-left: 1px solid var(--border); box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, .38); transform: translateX(100%); transition: transform 220ms ease, visibility 220ms; }
|
||||||
|
.notification-centre.is-open { visibility: visible; transform: translateX(0); }
|
||||||
|
.notification-centre > header { display: flex; align-items: center; justify-content: space-between; min-height: 5.5rem; padding: 1.2rem 1.25rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.notification-centre h2 { font-size: 1.35rem; letter-spacing: -.025em; }
|
||||||
|
.notification-centre .eyebrow { margin-bottom: .25rem; }
|
||||||
|
.notification-close { display: grid; place-items: center; width: 2.35rem; height: 2.35rem; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: .6rem; font-size: 1.5rem; line-height: 1; }
|
||||||
|
.notification-list { min-height: 0; padding: 0; margin: 0; overflow-y: auto; list-style: none; }
|
||||||
|
.notification-list li { border-bottom: 1px solid var(--border); }
|
||||||
|
.notification-entry { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .8rem; padding: 1rem 1.25rem; color: inherit; text-decoration: none; }
|
||||||
|
a.notification-entry:hover { background: var(--surface); }
|
||||||
|
.notification-signal { width: .55rem; height: .55rem; margin-top: .3rem; background: #77a6d8; border-radius: 50%; box-shadow: 0 0 0 .3rem rgba(119, 166, 216, .1); }
|
||||||
|
.notification-signal.success { background: var(--accent); box-shadow: 0 0 0 .3rem rgba(117, 214, 157, .1); }
|
||||||
|
.notification-signal.warning { background: var(--warning); box-shadow: 0 0 0 .3rem rgba(243, 199, 107, .1); }
|
||||||
|
.notification-signal.error { background: var(--danger); box-shadow: 0 0 0 .3rem rgba(255, 138, 132, .1); }
|
||||||
|
.notification-copy { display: grid; gap: .28rem; min-width: 0; }
|
||||||
|
.notification-copy strong { color: #edf5f0; font-size: .9rem; line-height: 1.3; }
|
||||||
|
.notification-copy > span { overflow: hidden; color: var(--muted); font-size: .82rem; line-height: 1.45; text-overflow: ellipsis; }
|
||||||
|
.notification-copy time { color: #71857b; font-size: .7rem; }
|
||||||
|
.notification-centre > footer { display: flex; justify-content: flex-end; padding: .8rem 1.25rem; border-top: 1px solid var(--border); }
|
||||||
|
.notification-clear { padding: .4rem 0; color: var(--muted); background: transparent; border: 0; font-size: .78rem; font-weight: 700; }
|
||||||
|
.notification-clear:hover { color: #edf5f0; }
|
||||||
|
.notification-empty { display: grid; place-items: center; align-content: center; padding: 2rem; text-align: center; }
|
||||||
|
.notification-empty > span { display: grid; place-items: center; width: 3.25rem; height: 3.25rem; margin-bottom: .9rem; color: var(--accent); background: var(--accent-dark); border: 1px solid #28593d; border-radius: 1rem; }
|
||||||
|
.notification-empty strong { margin-bottom: .35rem; font-size: 1rem; }
|
||||||
|
.notification-empty p { max-width: 18rem; margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.5; }
|
||||||
|
.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); pointer-events: none; }
|
||||||
|
.notification-toast { position: relative; overflow: hidden; background: #15221c; border: 1px solid #355044; border-radius: .85rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .4); pointer-events: auto; animation: notification-arrive 220ms ease-out both; }
|
||||||
|
.notification-toast.warning { background: #282112; border-color: #5b4922; }
|
||||||
|
.notification-toast.error { background: #2d1715; border-color: #69332e; }
|
||||||
|
.notification-toast > a, .notification-toast > div { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .75rem; padding: .9rem 2.75rem .9rem 1rem; color: inherit; text-decoration: none; }
|
||||||
|
.notification-toast > a:hover { background: rgba(255, 255, 255, .035); }
|
||||||
|
.notification-toast .notification-copy time { display: none; }
|
||||||
|
.notification-toast > button { position: absolute; top: .5rem; right: .5rem; width: 1.8rem; height: 1.8rem; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: .35rem; font-size: 1.2rem; }
|
||||||
|
@keyframes notification-arrive { from { opacity: 0; transform: translateY(.75rem) scale(.98); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .notification-centre, .notification-toast { transition: none; animation: none; } }
|
||||||
|
@media (max-width: 47.999rem) { .brand .notification-bell { display: none; } .toast-stack { bottom: .75rem; } }
|
||||||
|
@media (min-width: 48rem) { .mobile-header .notification-bell { display: none; } }
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type PropsWithChildren,
|
||||||
|
} from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useDashboardRealtime } from './realtime-provider.js';
|
||||||
|
import './notifications.css';
|
||||||
|
|
||||||
|
export type NotificationTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
occurredAt: string;
|
||||||
|
tone: NotificationTone;
|
||||||
|
href?: string;
|
||||||
|
isRead: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationDraft = Omit<NotificationItem, 'isRead'>;
|
||||||
|
|
||||||
|
interface NotificationContextValue {
|
||||||
|
isOpen: boolean;
|
||||||
|
notifications: readonly NotificationItem[];
|
||||||
|
toasts: readonly NotificationItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
clearAll(): void;
|
||||||
|
dismissToast(id: string): void;
|
||||||
|
setOpen(isOpen: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealtimeSubscription {
|
||||||
|
subscribe(listener: (event: DashboardEvent) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'reddit-bot.notifications.v1';
|
||||||
|
const MAX_NOTIFICATIONS = 50;
|
||||||
|
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
|
const StoredNotificationsSchema = z.array(z.strictObject({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
occurredAt: z.string(),
|
||||||
|
tone: z.enum(['info', 'success', 'warning', 'error']),
|
||||||
|
href: z.string().optional(),
|
||||||
|
isRead: z.boolean(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function notificationStorage(): Storage | undefined {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1).replaceAll('-', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationForEvent(event: DashboardEvent): NotificationDraft | null {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'reply.received':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `New reply from u/${event.payload.sender}`,
|
||||||
|
message: event.payload.preview?.trim() || 'A new message arrived in this conversation.',
|
||||||
|
tone: 'success',
|
||||||
|
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
|
||||||
|
};
|
||||||
|
case 'message.sent':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `Message sent to u/${event.payload.recipient}`,
|
||||||
|
message: 'The message was delivered successfully.',
|
||||||
|
tone: 'success',
|
||||||
|
href: event.payload.conversationId
|
||||||
|
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
|
||||||
|
: '/conversations',
|
||||||
|
};
|
||||||
|
case 'message.failed':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `Message to u/${event.payload.recipient} failed`,
|
||||||
|
message: event.payload.reason,
|
||||||
|
tone: 'error',
|
||||||
|
href: event.payload.conversationId
|
||||||
|
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
|
||||||
|
: '/operations',
|
||||||
|
};
|
||||||
|
case 'bot.status.changed':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `Bot ${event.payload.status}`,
|
||||||
|
message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
|
||||||
|
tone: event.payload.status === 'running' ? 'success' : event.payload.status === 'stopped' ? 'warning' : 'info',
|
||||||
|
href: '/operations',
|
||||||
|
};
|
||||||
|
case 'sync.completed':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `${titleCase(event.payload.kind)} sync complete`,
|
||||||
|
message: `${event.payload.processed.toLocaleString()} items processed.`,
|
||||||
|
tone: 'success',
|
||||||
|
href: '/operations',
|
||||||
|
};
|
||||||
|
case 'sync.failed':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `${titleCase(event.payload.kind)} sync failed`,
|
||||||
|
message: event.payload.reason,
|
||||||
|
tone: 'error',
|
||||||
|
href: '/operations',
|
||||||
|
};
|
||||||
|
case 'rate-limit.changed':
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: event.payload.limited ? 'Rate limit reached' : 'Rate limit cleared',
|
||||||
|
message: event.payload.reason ?? (event.payload.limited
|
||||||
|
? 'Sending will resume when the limit resets.'
|
||||||
|
: 'The bot can send messages again.'),
|
||||||
|
tone: event.payload.limited ? 'warning' : 'success',
|
||||||
|
href: '/operations',
|
||||||
|
};
|
||||||
|
case 'reddit.session.changed':
|
||||||
|
if (!['authenticated', 'expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: event.payload.status === 'authenticated' ? 'Reddit account connected' : 'Reddit account needs attention',
|
||||||
|
message: event.payload.reason ?? (event.payload.username
|
||||||
|
? `Signed in as u/${event.payload.username}.`
|
||||||
|
: `Session status changed to ${event.payload.status}.`),
|
||||||
|
tone: event.payload.status === 'authenticated' ? 'success' : 'error',
|
||||||
|
href: '/settings',
|
||||||
|
};
|
||||||
|
case 'conversation.updated':
|
||||||
|
case 'sync.started':
|
||||||
|
case 'sync.progress':
|
||||||
|
case 'log.created':
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifications(): NotificationItem[] {
|
||||||
|
const storage = notificationStorage();
|
||||||
|
if (!storage) return [];
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? '[]');
|
||||||
|
const parsed = StoredNotificationsSchema.safeParse(value);
|
||||||
|
return parsed.success ? parsed.data.slice(0, MAX_NOTIFICATIONS) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationProviderProps extends PropsWithChildren {
|
||||||
|
realtime?: RealtimeSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationProvider({ children, realtime: realtimeOverride }: NotificationProviderProps) {
|
||||||
|
const dashboardRealtime = useDashboardRealtime();
|
||||||
|
const realtime = realtimeOverride ?? dashboardRealtime;
|
||||||
|
const [isOpen, setIsOpenState] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState<NotificationItem[]>(loadNotifications);
|
||||||
|
const [toasts, setToasts] = useState<NotificationItem[]>([]);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: string) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setOpen = useCallback((nextOpen: boolean) => {
|
||||||
|
setIsOpenState(nextOpen);
|
||||||
|
if (nextOpen) setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
notificationStorage()?.setItem(STORAGE_KEY, JSON.stringify(notifications));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private or restricted browser contexts.
|
||||||
|
}
|
||||||
|
}, [notifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && notifications.some((item) => !item.isRead)) {
|
||||||
|
setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
|
||||||
|
}
|
||||||
|
}, [isOpen, notifications]);
|
||||||
|
|
||||||
|
useEffect(() => realtime.subscribe((event) => {
|
||||||
|
const draft = notificationForEvent(event);
|
||||||
|
if (!draft) return;
|
||||||
|
const item = { ...draft, isRead: false };
|
||||||
|
setNotifications((current) => current.some((entry) => entry.id === item.id)
|
||||||
|
? current
|
||||||
|
: [item, ...current].slice(0, MAX_NOTIFICATIONS));
|
||||||
|
setToasts((current) => current.some((entry) => entry.id === item.id)
|
||||||
|
? current
|
||||||
|
: [...current, item].slice(-3));
|
||||||
|
}), [realtime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (toasts.length === 0) return undefined;
|
||||||
|
const timers = toasts.map((toast) => globalThis.setTimeout(
|
||||||
|
() => dismissToast(toast.id),
|
||||||
|
toast.tone === 'error' ? 8_000 : 5_000,
|
||||||
|
));
|
||||||
|
return () => timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||||
|
}, [dismissToast, toasts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function closeOnEscape(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') setOpen(false);
|
||||||
|
}
|
||||||
|
globalThis.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => globalThis.removeEventListener('keydown', closeOnEscape);
|
||||||
|
}, [setOpen]);
|
||||||
|
|
||||||
|
const value = useMemo<NotificationContextValue>(() => ({
|
||||||
|
isOpen,
|
||||||
|
notifications,
|
||||||
|
toasts,
|
||||||
|
unreadCount: notifications.filter((item) => !item.isRead).length,
|
||||||
|
clearAll: () => setNotifications([]),
|
||||||
|
dismissToast,
|
||||||
|
setOpen,
|
||||||
|
}), [dismissToast, isOpen, notifications, setOpen, toasts]);
|
||||||
|
|
||||||
|
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNotifications(): NotificationContextValue {
|
||||||
|
const context = useContext(NotificationContext);
|
||||||
|
if (!context) throw new Error('Notifications must be used within NotificationProvider');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BellIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||||
|
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell({ className = '' }: { className?: string }) {
|
||||||
|
const { isOpen, setOpen, unreadCount } = useNotifications();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-controls="notification-centre"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
||||||
|
className={`notification-bell ${className}`.trim()}
|
||||||
|
onClick={() => setOpen(!isOpen)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BellIcon />
|
||||||
|
{unreadCount > 0 ? <span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formattedTime(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationContent({ item }: { item: NotificationItem }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={`notification-signal ${item.tone}`} aria-hidden="true" />
|
||||||
|
<span className="notification-copy">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>{item.message}</span>
|
||||||
|
<time dateTime={item.occurredAt}>{formattedTime(item.occurredAt)}</time>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationViewport() {
|
||||||
|
const { clearAll, dismissToast, isOpen, notifications, setOpen, toasts } = useNotifications();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOpen ? <button aria-label="Close notifications" className="notification-backdrop" onClick={() => setOpen(false)} type="button" /> : null}
|
||||||
|
<aside aria-label="Notifications" className={isOpen ? 'notification-centre is-open' : 'notification-centre'} id="notification-centre">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Activity inbox</span>
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
</div>
|
||||||
|
<button aria-label="Close notification centre" className="notification-close" onClick={() => setOpen(false)} type="button">×</button>
|
||||||
|
</header>
|
||||||
|
{notifications.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ol className="notification-list">
|
||||||
|
{notifications.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{item.href ? (
|
||||||
|
<Link className="notification-entry" onClick={() => setOpen(false)} to={item.href}>
|
||||||
|
<NotificationContent item={item} />
|
||||||
|
</Link>
|
||||||
|
) : <div className="notification-entry"><NotificationContent item={item} /></div>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<footer><button className="notification-clear" onClick={clearAll} type="button">Clear all</button></footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="notification-empty">
|
||||||
|
<span aria-hidden="true"><BellIcon /></span>
|
||||||
|
<strong>All quiet</strong>
|
||||||
|
<p>Replies, delivery updates, syncs, and account alerts will appear here.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
<div aria-atomic="false" aria-live="polite" className="toast-stack">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div className={`notification-toast ${toast.tone}`} key={toast.id} role={toast.tone === 'error' ? 'alert' : 'status'}>
|
||||||
|
{toast.href ? (
|
||||||
|
<Link onClick={() => dismissToast(toast.id)} to={toast.href}><NotificationContent item={toast} /></Link>
|
||||||
|
) : <div><NotificationContent item={toast} /></div>}
|
||||||
|
<button aria-label={`Dismiss ${toast.title}`} onClick={() => dismissToast(toast.id)} type="button">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ nav { display: grid; gap: .35rem; }
|
|||||||
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
|
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
|
||||||
.workspace { min-height: 100vh; }
|
.workspace { min-height: 100vh; }
|
||||||
.mobile-header { position: sticky; top: 0; z-index: 15; display: flex; align-items: center; justify-content: space-between; height: 4rem; padding: 0 1rem; background: rgba(9, 17, 14, .92); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); font-weight: 700; }
|
.mobile-header { position: sticky; top: 0; z-index: 15; display: flex; align-items: center; justify-content: space-between; height: 4rem; padding: 0 1rem; background: rgba(9, 17, 14, .92); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); font-weight: 700; }
|
||||||
|
.mobile-header-actions { display: flex; gap: .55rem; }
|
||||||
.icon-button { width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: .6rem; color: white; background: var(--surface); font-size: 1.25rem; }
|
.icon-button { width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: .6rem; color: white; background: var(--surface); font-size: 1.25rem; }
|
||||||
main { min-height: calc(100vh - 4rem); }
|
main { min-height: calc(100vh - 4rem); }
|
||||||
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { act, cleanup, render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { DashboardEvent } from '../../packages/api-contracts/src/index.js';
|
||||||
|
import {
|
||||||
|
NotificationBell,
|
||||||
|
NotificationProvider,
|
||||||
|
NotificationViewport,
|
||||||
|
notificationForEvent,
|
||||||
|
} from '../../apps/dashboard/src/app/notifications.js';
|
||||||
|
|
||||||
|
class FakeRealtime {
|
||||||
|
private listener?: (event: DashboardEvent) => void;
|
||||||
|
|
||||||
|
subscribe(listener: (event: DashboardEvent) => void): () => void {
|
||||||
|
this.listener = listener;
|
||||||
|
return () => { this.listener = undefined; };
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: DashboardEvent): void {
|
||||||
|
this.listener?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dashboard notifications', () => {
|
||||||
|
it('turns actionable realtime events into useful notification copy', () => {
|
||||||
|
expect(notificationForEvent({
|
||||||
|
id: 'reply-1',
|
||||||
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||||
|
type: 'reply.received',
|
||||||
|
payload: { conversationId: 'room/one', replyId: 'message-1', sender: 'alice', preview: 'Sounds good!' },
|
||||||
|
})).toMatchObject({
|
||||||
|
title: 'New reply from u/alice',
|
||||||
|
message: 'Sounds good!',
|
||||||
|
tone: 'success',
|
||||||
|
href: '/conversations/room%2Fone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notificationForEvent({
|
||||||
|
id: 'log-1',
|
||||||
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||||
|
type: 'log.created',
|
||||||
|
payload: { level: 'info', message: 'Routine detail' },
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows new events as toasts and keeps them in a readable notification centre', async () => {
|
||||||
|
const realtime = new FakeRealtime();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<NotificationProvider realtime={realtime}>
|
||||||
|
<NotificationBell />
|
||||||
|
<NotificationViewport />
|
||||||
|
</NotificationProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||||
|
act(() => realtime.emit({
|
||||||
|
id: 'reply-1',
|
||||||
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||||
|
type: 'reply.received',
|
||||||
|
payload: { conversationId: 'conversation-1', replyId: 'message-1', sender: 'alice', preview: 'Sounds good!' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toast = screen.getByRole('status');
|
||||||
|
expect(toast).toHaveTextContent('New reply from u/alice');
|
||||||
|
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Notifications, 1 unread' }));
|
||||||
|
const centre = screen.getByLabelText('Notifications', { selector: 'aside' });
|
||||||
|
expect(centre).toHaveClass('is-open');
|
||||||
|
expect(within(centre).getByText('Sounds good!')).toBeInTheDocument();
|
||||||
|
expect(within(centre).getByRole('link', { name: /New reply from u\/alice/ })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/conversations/conversation-1',
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(centre).getByRole('button', { name: 'Clear all' }));
|
||||||
|
expect(within(centre).getByText('All quiet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user