Make dashboard event toasts persistent
This commit is contained in:
1 parent
4798385d8b
commit
c75e71f26e
4 files changed
+89
-12
No files matched your search
@@ -43,7 +43,7 @@ a.notification-entry:hover { background: var(--surface); }
|
|||||||
.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 > 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 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; }
|
.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; }
|
.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); overflow-y: auto; 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 { 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.warning { background: #282112; border-color: #5b4922; }
|
||||||
.notification-toast.error { background: #2d1715; border-color: #69332e; }
|
.notification-toast.error { background: #2d1715; border-color: #69332e; }
|
||||||
|
|||||||
@@ -168,6 +168,34 @@ 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[] {
|
function loadNotifications(): NotificationItem[] {
|
||||||
const storage = notificationStorage();
|
const storage = notificationStorage();
|
||||||
if (!storage) return [];
|
if (!storage) return [];
|
||||||
@@ -221,20 +249,13 @@ export function NotificationProvider({ children, realtime: realtimeOverride }: N
|
|||||||
setNotifications((current) => current.some((entry) => entry.id === item.id)
|
setNotifications((current) => current.some((entry) => entry.id === item.id)
|
||||||
? current
|
? current
|
||||||
: [item, ...current].slice(0, MAX_NOTIFICATIONS));
|
: [item, ...current].slice(0, MAX_NOTIFICATIONS));
|
||||||
|
if (shouldToastForEvent(event)) {
|
||||||
setToasts((current) => current.some((entry) => entry.id === item.id)
|
setToasts((current) => current.some((entry) => entry.id === item.id)
|
||||||
? current
|
? current
|
||||||
: [...current, item].slice(-3));
|
: [...current, item]);
|
||||||
|
}
|
||||||
}), [realtime]);
|
}), [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(() => {
|
useEffect(() => {
|
||||||
function closeOnEscape(event: KeyboardEvent): void {
|
function closeOnEscape(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Escape') setOpen(false);
|
if (event.key === 'Escape') setOpen(false);
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ Rules:
|
|||||||
- Duplicate/replayed events must be harmless.
|
- Duplicate/replayed events must be harmless.
|
||||||
- A disconnected stream marks data stale but does not blank useful cached content.
|
- A disconnected stream marks data stale but does not blank useful cached content.
|
||||||
- Invalid event payloads are contained and repaired with a snapshot refresh.
|
- Invalid event payloads are contained and repaired with a snapshot refresh.
|
||||||
- User-triggered outcomes may create toasts; durable/history-worthy events also appear in the notification center/activity views.
|
- The notification centre retains useful lifecycle history, including successful syncs. Sticky toasts are reserved for inbound replies/media and attention-worthy failures or runtime/account states; they remain visible until the operator dismisses them.
|
||||||
- Raw technical logs belong in Operations, not as general user notifications.
|
- Raw technical logs belong in Operations, not as general user notifications.
|
||||||
|
|
||||||
## Authentication and privacy
|
## Authentication and privacy
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
NotificationProvider,
|
NotificationProvider,
|
||||||
NotificationViewport,
|
NotificationViewport,
|
||||||
notificationForEvent,
|
notificationForEvent,
|
||||||
|
shouldToastForEvent,
|
||||||
} from '../../apps/dashboard/src/app/notifications.js';
|
} from '../../apps/dashboard/src/app/notifications.js';
|
||||||
|
|
||||||
class FakeRealtime {
|
class FakeRealtime {
|
||||||
@@ -28,7 +29,9 @@ class FakeRealtime {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
globalThis.localStorage.clear();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dashboard notifications', () => {
|
describe('dashboard notifications', () => {
|
||||||
@@ -90,4 +93,57 @@ describe('dashboard notifications', () => {
|
|||||||
await user.click(within(centre).getByRole('button', { name: 'Clear all' }));
|
await user.click(within(centre).getByRole('button', { name: 'Clear all' }));
|
||||||
expect(within(centre).getByText('All quiet')).toBeInTheDocument();
|
expect(within(centre).getByText('All quiet')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps routine sync completions in the notification centre without showing a toast', async () => {
|
||||||
|
const realtime = new FakeRealtime();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<NotificationProvider realtime={realtime}>
|
||||||
|
<NotificationBell />
|
||||||
|
<NotificationViewport />
|
||||||
|
</NotificationProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncEvent: DashboardEvent = {
|
||||||
|
id: 'sync-1',
|
||||||
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||||
|
type: 'sync.completed',
|
||||||
|
payload: { syncId: 'conversation-sync-1', kind: 'conversations', processed: 12 },
|
||||||
|
};
|
||||||
|
expect(shouldToastForEvent(syncEvent)).toBe(false);
|
||||||
|
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'))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps important event toasts visible until they are dismissed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const realtime = new FakeRealtime();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<NotificationProvider realtime={realtime}>
|
||||||
|
<NotificationViewport />
|
||||||
|
</NotificationProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => realtime.emit({
|
||||||
|
id: 'media-1',
|
||||||
|
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||||
|
type: 'media.received',
|
||||||
|
payload: { conversationId: 'conversation-1', sender: 'alice', count: 1 },
|
||||||
|
}));
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Image received from u/alice');
|
||||||
|
|
||||||
|
act(() => vi.advanceTimersByTime(60_000));
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => screen.getByRole('button', { name: 'Dismiss Image received from u/alice' }).click());
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user