333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
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 'media.received':
|
||
return {
|
||
id: event.id,
|
||
occurredAt: event.occurredAt,
|
||
title: `Image received from u/${event.payload.sender}`,
|
||
message: event.payload.count > 1
|
||
? `${event.payload.count} images are attached to this conversation.`
|
||
: 'An image is attached to this conversation.',
|
||
tone: 'success',
|
||
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
|
||
};
|
||
case 'message.sent':
|
||
return null;
|
||
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':
|
||
if (event.payload.status !== 'stopped') return null;
|
||
return {
|
||
id: event.id,
|
||
occurredAt: event.occurredAt,
|
||
title: `Bot ${event.payload.status}`,
|
||
message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
|
||
tone: 'warning',
|
||
href: '/operations',
|
||
};
|
||
case 'sync.completed':
|
||
return null;
|
||
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':
|
||
if (!event.payload.limited) return null;
|
||
return {
|
||
id: event.id,
|
||
occurredAt: event.occurredAt,
|
||
title: 'Rate limit reached',
|
||
message: event.payload.reason ?? 'Sending will resume when the limit resets.',
|
||
tone: 'warning',
|
||
href: '/operations',
|
||
};
|
||
case 'reddit.session.changed':
|
||
if (!['expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
|
||
return {
|
||
id: event.id,
|
||
occurredAt: event.occurredAt,
|
||
title: 'Reddit account needs attention',
|
||
message: event.payload.reason ?? (event.payload.username
|
||
? `The session for u/${event.payload.username} changed to ${event.payload.status}.`
|
||
: `Session status changed to ${event.payload.status}.`),
|
||
tone: 'error',
|
||
href: '/accounts',
|
||
};
|
||
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]);
|
||
}), [realtime]);
|
||
|
||
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>New replies, received images, and important 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>
|
||
</>
|
||
);
|
||
}
|