The settings page had accumulated five tabs whose names no longer matched their contents, three different save models, and the whole subreddit configuration buried under a tab named after a checkbox. Structure: - Subreddits becomes its own page: a list showing each community's mode, tone, computed share of polling, and live sent/reply stats, with one subreddit edited at a time in a drawer. - Accounts becomes its own page. It is session lifecycle, not config. - Settings collapses to one scrolling page: Safety, Pacing (Monitoring and Rate limits merged), Targeting, Notifications, Advanced. - The sidebar is grouped, having gone from 9 to 11 destinations. Saving: - Every section owns its own draft, validation, risk gate and save via useConfigCard, submitting only the keys it owns. A syntax error in the advanced JSON can no longer block a rate-limit change, and enabling an autonomous policy is its own deliberate commit. - UnsavedChangesProvider restores the leave-page guard across cards that no longer share a draft. Subreddit editing: - Errors are keyed by field name instead of array index, removing the defensive invalidation that existed because row indices shifted on add and remove. Confirming an autonomous mode can no longer be attributed to the wrong row. - Weight is shown as the share it actually produces, live while editing. - Bulk actions cover start/pause polling, tone, policy and removal, because production runs 30-40 subreddits and per-row editing does not scale. Bulk policy changes name every subreddit turning autonomous, and normalise the hybrid mode's turn budget, which has no form to catch it. Physique targeting moves into its own Targeting card. It sends only reddit.physiqueTargeting, which the config route applies independently, so it cannot clobber the pacing card's fields. Turning screening on now warns that it will reduce how many posts qualify. Also surfaces the effective sending rate, since the hourly limit and the minimum delay both throttle and only the binding one matters. Legacy ?section= links redirect or resolve to anchors. Fixes found while verifying in a browser: a radio-card rule was shrinking nested number inputs; .share-preview had no styles; the throughput readout reported a rate from a blank field; and the reconcile logic warned about a server change whenever a sibling card saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
13 KiB
TypeScript
358 lines
13 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 {
|
||
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: '/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].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>
|
||
</>
|
||
);
|
||
}
|