From 3bb1dda6bb1f18c2288378c2742c7027ff519afc Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Fri, 14 Aug 2026 09:13:36 +0100 Subject: [PATCH] Keep routine events out of notifications --- apps/dashboard/src/app/notifications.tsx | 78 +++++------------------- tests/dashboard/notifications.test.tsx | 31 ++++++++-- 2 files changed, 41 insertions(+), 68 deletions(-) diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx index 365e516..77b1b75 100644 --- a/apps/dashboard/src/app/notifications.tsx +++ b/apps/dashboard/src/app/notifications.tsx @@ -89,16 +89,7 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | 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', - }; + return null; case 'message.failed': return { id: event.id, @@ -111,23 +102,17 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | : '/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: event.payload.status === 'running' ? 'success' : event.payload.status === 'stopped' ? 'warning' : 'info', + tone: 'warning', 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', - }; + return null; case 'sync.failed': return { id: event.id, @@ -138,26 +123,25 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | href: '/operations', }; case 'rate-limit.changed': + if (!event.payload.limited) return null; 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', + title: 'Rate limit reached', + message: event.payload.reason ?? 'Sending will resume when the limit resets.', + tone: 'warning', href: '/operations', }; case 'reddit.session.changed': - if (!['authenticated', 'expired', 'logged-out', 'error'].includes(event.payload.status)) return null; + if (!['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', + title: 'Reddit account needs attention', message: event.payload.reason ?? (event.payload.username - ? `Signed in as u/${event.payload.username}.` + ? `The session for u/${event.payload.username} changed to ${event.payload.status}.` : `Session status changed to ${event.payload.status}.`), - tone: event.payload.status === 'authenticated' ? 'success' : 'error', + tone: 'error', href: '/accounts', }; case 'conversation.updated': @@ -168,34 +152,6 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | } } -/** - * Toasts interrupt the operator, so reserve them for inbound activity and - * states that need attention. Routine lifecycle and successful sync events - * remain available in the notification centre without producing a pop-up. - */ -export function shouldToastForEvent(event: DashboardEvent): boolean { - switch (event.type) { - case 'reply.received': - case 'media.received': - case 'message.failed': - case 'sync.failed': - return true; - case 'bot.status.changed': - return event.payload.status === 'stopped'; - case 'rate-limit.changed': - return event.payload.limited; - case 'reddit.session.changed': - return ['expired', 'logged-out', 'error'].includes(event.payload.status); - case 'message.sent': - case 'conversation.updated': - case 'sync.started': - case 'sync.progress': - case 'sync.completed': - case 'log.created': - return false; - } -} - function loadNotifications(): NotificationItem[] { const storage = notificationStorage(); if (!storage) return []; @@ -249,11 +205,9 @@ export function NotificationProvider({ children, realtime: realtimeOverride }: N setNotifications((current) => current.some((entry) => entry.id === item.id) ? current : [item, ...current].slice(0, MAX_NOTIFICATIONS)); - if (shouldToastForEvent(event)) { - setToasts((current) => current.some((entry) => entry.id === item.id) - ? current - : [...current, item]); - } + setToasts((current) => current.some((entry) => entry.id === item.id) + ? current + : [...current, item]); }), [realtime]); useEffect(() => { @@ -359,7 +313,7 @@ export function NotificationViewport() {
All quiet -

Replies, delivery updates, syncs, and account alerts will appear here.

+

New replies, received images, and important account alerts will appear here.

)} diff --git a/tests/dashboard/notifications.test.tsx b/tests/dashboard/notifications.test.tsx index 5f62366..31c8385 100644 --- a/tests/dashboard/notifications.test.tsx +++ b/tests/dashboard/notifications.test.tsx @@ -11,7 +11,6 @@ import { NotificationProvider, NotificationViewport, notificationForEvent, - shouldToastForEvent, } from '../../apps/dashboard/src/app/notifications.js'; class FakeRealtime { @@ -54,6 +53,27 @@ describe('dashboard notifications', () => { type: 'log.created', payload: { level: 'info', message: 'Routine detail' }, })).toBeNull(); + + expect(notificationForEvent({ + id: 'sent-1', + occurredAt: '2026-08-09T09:30:00.000Z', + type: 'message.sent', + payload: { messageId: 'message-2', recipient: 'alice', conversationId: 'room/one' }, + })).toBeNull(); + + expect(notificationForEvent({ + id: 'running-1', + occurredAt: '2026-08-09T09:30:00.000Z', + type: 'bot.status.changed', + payload: { status: 'running', dryRun: false }, + })).toBeNull(); + + expect(notificationForEvent({ + id: 'rate-clear-1', + occurredAt: '2026-08-09T09:30:00.000Z', + type: 'rate-limit.changed', + payload: { limited: false }, + })).toBeNull(); }); it('shows new events as toasts and keeps them in a readable notification centre', async () => { @@ -94,9 +114,8 @@ describe('dashboard notifications', () => { expect(within(centre).getByText('All quiet')).toBeInTheDocument(); }); - it('keeps routine sync completions in the notification centre without showing a toast', async () => { + it('ignores routine sync completions entirely', () => { const realtime = new FakeRealtime(); - const user = userEvent.setup(); render( @@ -112,12 +131,12 @@ describe('dashboard notifications', () => { type: 'sync.completed', payload: { syncId: 'conversation-sync-1', kind: 'conversations', processed: 12 }, }; - expect(shouldToastForEvent(syncEvent)).toBe(false); + expect(notificationForEvent(syncEvent)).toBeNull(); act(() => realtime.emit(syncEvent)); expect(screen.queryByRole('status')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Notifications, 1 unread' })); - expect(within(screen.getByLabelText('Notifications', { selector: 'aside' })).getByText('Conversations sync complete')) + expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument(); + expect(within(screen.getByLabelText('Notifications', { selector: 'aside' })).getByText('All quiet')) .toBeInTheDocument(); });