fix(notifications): request OS permission at startup and fix socketService listener leak (#998)

This commit is contained in:
Mega Mind
2026-04-28 18:56:44 -07:00
committed by GitHub
parent dcbcf7cc4e
commit 23a18c9c3f
21 changed files with 893 additions and 39 deletions
+54 -9
View File
@@ -10,10 +10,14 @@
*
* Mounted once at AppShell root.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { showNativeNotification } from '../lib/nativeNotifications/tauriBridge';
import {
ensureNotificationPermission,
showNativeNotification,
} from '../lib/nativeNotifications/tauriBridge';
import { purgeWebviewAccount } from '../services/webviewAccountService';
import { addAccount, removeAccount } from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
@@ -181,21 +185,53 @@ function renderBody(path: string, close: () => void) {
// ── Notifications ────────────────────────────────────────────────────────
const NotificationsBody = ({ close }: { close: () => void }) => {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [status, setStatus] = useState<'idle' | 'sending' | 'queued' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const TEST_NOTIFICATION_DELAY_MS = 3500;
const notificationTimeoutRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
return () => {
cancelledRef.current = true;
if (notificationTimeoutRef.current !== null) {
window.clearTimeout(notificationTimeoutRef.current);
notificationTimeoutRef.current = null;
}
};
}, []);
const handleAllow = async () => {
setStatus('sending');
setError(null);
try {
// First send triggers the OS permission prompt on macOS / Windows.
// Once granted the notification appears and subsequent calls
// succeed silently.
const granted = await ensureNotificationPermission();
if (coreIsTauri() && !granted) {
setStatus('error');
setError(
'Notification permission was denied. Please enable notifications for OpenHuman in System Settings Notifications.'
);
return;
}
// Delay the test ping so users can move focus away from OpenHuman.
// macOS commonly suppresses banner popouts while the app is foreground.
setStatus('queued');
await new Promise<void>(resolve => {
notificationTimeoutRef.current = window.setTimeout(() => {
notificationTimeoutRef.current = null;
resolve();
}, TEST_NOTIFICATION_DELAY_MS);
});
if (cancelledRef.current) {
return;
}
await showNativeNotification({
title: 'OpenHuman is good to go',
body: 'You will get pings here when something needs your attention.',
tag: 'welcome-notification-test',
});
if (cancelledRef.current) {
return;
}
setStatus('sent');
} catch (e) {
setStatus('error');
@@ -213,10 +249,19 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
<button
type="button"
onClick={() => void handleAllow()}
disabled={status === 'sending'}
disabled={status === 'sending' || status === 'queued'}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60">
{status === 'sending' ? 'Asking your OS' : 'Send test notification'}
{status === 'sending'
? 'Asking your OS'
: status === 'queued'
? 'Sending test in 3.5s switch apps now'
: 'Send test notification'}
</button>
{status === 'queued' && (
<p className="text-xs text-stone-600">
Timer started. Switch to another app for a moment so the banner can pop out.
</p>
)}
{status === 'sent' && (
<p className="text-xs text-sage-700">
Sent. If you saw a pop-up in the corner, you're all set. If your OS asked for permission,
@@ -50,12 +50,21 @@ function scoreBadgeClass(score: number): string {
interface Props {
notification: IntegrationNotification;
onMarkRead: (id: string) => void;
onNavigate?: (id: string) => void;
onDismiss?: (id: string) => void;
}
const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) => {
const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => {
const isUnread = n.status === 'unread';
const handleBodyClick = () => {
if (onNavigate) {
onNavigate(n.id);
} else if (isUnread) {
onMarkRead(n.id);
}
};
return (
<div
className={`w-full p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 ${
@@ -70,9 +79,7 @@ const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) =>
</div>
<button
onClick={() => {
if (isUnread) onMarkRead(n.id);
}}
onClick={handleBodyClick}
className="flex-1 min-w-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 rounded-sm">
{/* Header row: provider badge + timestamp */}
<div className="flex items-center gap-2 mb-1">
@@ -1,13 +1,17 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { resolveIntegrationRoute } from '../../lib/notificationRouter';
import {
dismissNotification,
fetchNotifications,
markNotificationActed,
markNotificationRead,
} from '../../services/notificationService';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
dismissIntegrationNotification,
markIntegrationActed,
markIntegrationRead,
setIntegrationError,
setIntegrationLoading,
@@ -21,6 +25,7 @@ import NotificationCard from './NotificationCard';
const NotificationCenter = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const {
integrationItems: items,
integrationLoading: loading,
@@ -73,6 +78,20 @@ const NotificationCenter = () => {
}
};
/** Navigate to the resolved route for the notification and mark it as acted. */
const handleNavigate = async (id: string) => {
const n = items.find(i => i.id === id);
if (!n) return;
const route = resolveIntegrationRoute(n);
dispatch(markIntegrationActed(id));
navigate(route);
try {
await markNotificationActed(id);
} catch {
// Optimistic update already applied; failure is non-critical.
}
};
const handleDismiss = async (id: string) => {
dispatch(dismissIntegrationNotification(id));
try {
@@ -191,6 +210,9 @@ const NotificationCenter = () => {
onMarkRead={id => {
void handleMarkRead(id);
}}
onNavigate={id => {
void handleNavigate(id);
}}
onDismiss={id => {
void handleDismiss(id);
}}
+1 -1
View File
@@ -1,2 +1,2 @@
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
export { showNativeNotification } from './tauriBridge';
export { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
+25 -3
View File
@@ -7,7 +7,7 @@ import {
type NotificationItem,
notificationReceived,
} from '../../store/notificationSlice';
import { showNativeNotification } from './tauriBridge';
import { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
const log = debug('native-notifications');
@@ -52,18 +52,27 @@ function dispatchAndMaybeBanner(
timestampOverride?: number
): void {
const prefs = store.getState().notifications.preferences;
log(
'[dispatch] category=%s id=%s enabled=%s focused=%s',
category,
item.id,
prefs[category],
windowIsFocused()
);
if (!prefs[category]) {
log('category %s disabled, skipping', category);
return;
}
const timestamp = timestampOverride && timestampOverride > 0 ? timestampOverride : Date.now();
const full: NotificationItem = { ...item, category, timestamp, read: false };
log('[dispatch] enqueue id=%s title=%s', full.id, full.title);
store.dispatch(notificationReceived(full));
// Only fire OS-level banner when the user isn't already looking at the
// window — otherwise the in-app center is enough and a native toast is
// redundant noise.
if (!windowIsFocused()) {
void showNativeNotification({ title: full.title, body: full.body, tag: full.id });
log('[dispatch] window unfocused, firing native banner id=%s', full.id);
void showNativeNotification({ title: full.title, body: full.body });
}
}
@@ -82,8 +91,15 @@ export function startNativeNotificationsService(): void {
if (started) return;
started = true;
// Request OS notification permission early so native banners can fire.
// Fire-and-forget — permission state is logged for diagnostics.
void ensureNotificationPermission().then(granted => {
log('notification permission ensured: granted=%s', granted);
});
chatDoneListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as ChatDonePayload;
log('[socket] chat_done');
dispatchAndMaybeBanner('agents', {
id: `chat_done:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
title: 'Agent reply ready',
@@ -94,6 +110,7 @@ export function startNativeNotificationsService(): void {
chatErrorListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as ChatErrorPayload;
log('[socket] chat_error');
dispatchAndMaybeBanner('system', {
id: `chat_error:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
title: 'Agent error',
@@ -107,7 +124,11 @@ export function startNativeNotificationsService(): void {
// bus. See src/openhuman/notifications/bus.rs.
coreNotificationListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as CoreNotificationPayload;
if (!p.id || !p.title) return;
log('[socket] core_notification id=%s category=%s', p.id, p.category);
if (!p.id || !p.title) {
log('[socket] core_notification missing id/title dropped');
return;
}
const serverTs = p.timestamp_ms && p.timestamp_ms > 0 ? p.timestamp_ms : Date.now();
dispatchAndMaybeBanner(
p.category,
@@ -123,6 +144,7 @@ export function startNativeNotificationsService(): void {
disconnectListener = (...args: unknown[]) => {
const reason = typeof args[0] === 'string' ? args[0] : 'unknown';
log('[socket] disconnect reason=%s', reason);
dispatchAndMaybeBanner('system', {
id: `socket_disconnect:${Date.now()}`,
title: 'Connection lost',
+35 -6
View File
@@ -7,7 +7,38 @@ const errLog = debug('native-notifications:bridge:error');
export interface ShowNativeNotificationArgs {
title: string;
body: string;
tag?: string;
}
/**
* Request OS notification permission if not already granted.
* Returns true if permission is (or was just) granted, false otherwise.
* No-op (returns false) when running outside Tauri.
*/
export async function ensureNotificationPermission(): Promise<boolean> {
if (!isTauri()) {
log('not running in tauri, skipping permission request');
return false;
}
try {
const grantedRaw = await invoke<boolean | null>('plugin:notification|is_permission_granted');
const granted = grantedRaw === true;
log('notification permission check (plugin): granted=%s raw=%o', granted, grantedRaw);
if (granted) return true;
const requestResult = await invoke<string>('plugin:notification|request_permission');
const requestState = String(requestResult ?? 'unknown').toLowerCase();
const nowGranted = requestState === 'granted' || requestState === 'provisional';
log('notification permission request result=%s granted=%s', requestState, nowGranted);
if (nowGranted) return true;
// Re-check once after request because some platforms may not return
// a definitive granted state from request_permission directly.
const pluginGranted = await invoke<boolean | null>('plugin:notification|is_permission_granted');
return pluginGranted === true;
} catch (err) {
errLog('ensureNotificationPermission failed: %O', err);
return false;
}
}
/**
@@ -20,12 +51,10 @@ export async function showNativeNotification(args: ShowNativeNotificationArgs):
return;
}
try {
await invoke('show_native_notification', {
title: args.title,
body: args.body,
tag: args.tag ?? null,
await invoke('plugin:notification|notify', {
options: { title: args.title, body: args.body, sound: 'default' },
});
} catch (err) {
errLog('show_native_notification failed: %O', err);
errLog('plugin:notification|notify failed: %O', err);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import type { NotificationItem } from '../store/notificationSlice';
import type { IntegrationNotification } from '../types/notifications';
import { resolveIntegrationRoute, resolveSystemRoute } from './notificationRouter';
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
const makeIntegration = (
overrides: Partial<IntegrationNotification> = {}
): IntegrationNotification => ({
id: 'i-1',
provider: 'slack',
title: 'Test',
body: 'Body',
raw_payload: {},
status: 'unread',
received_at: '2026-04-29T00:00:00Z',
...overrides,
});
const makeSystem = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 's-1',
category: 'messages',
title: 'Test',
body: 'Body',
timestamp: 1,
read: false,
...overrides,
});
// ─────────────────────────────────────────────────────────────────────────────
// resolveIntegrationRoute
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveIntegrationRoute', () => {
it('returns explicit deep_link when present', () => {
const n = makeIntegration({ deep_link: '/chat?account=abc' });
expect(resolveIntegrationRoute(n)).toBe('/chat?account=abc');
});
it.each(['gmail', 'slack', 'whatsapp', 'telegram', 'discord', 'linkedin'])(
'routes %s provider to /chat',
provider => {
expect(resolveIntegrationRoute(makeIntegration({ provider }))).toBe('/chat');
}
);
it('falls back to /notifications for unknown providers', () => {
expect(resolveIntegrationRoute(makeIntegration({ provider: 'unknown-app' }))).toBe(
'/notifications'
);
});
it('prefers deep_link over provider default', () => {
const n = makeIntegration({ provider: 'slack', deep_link: '/skills' });
expect(resolveIntegrationRoute(n)).toBe('/skills');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// resolveSystemRoute
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveSystemRoute', () => {
it('returns explicit deepLink when present', () => {
const item = makeSystem({ deepLink: '/skills' });
expect(resolveSystemRoute(item)).toBe('/skills');
});
it('routes messages category to /chat', () => {
expect(resolveSystemRoute(makeSystem({ category: 'messages' }))).toBe('/chat');
});
it('routes agents category to /chat', () => {
expect(resolveSystemRoute(makeSystem({ category: 'agents' }))).toBe('/chat');
});
it('routes skills category to /skills', () => {
expect(resolveSystemRoute(makeSystem({ category: 'skills' }))).toBe('/skills');
});
it('routes system category to /home', () => {
expect(resolveSystemRoute(makeSystem({ category: 'system' }))).toBe('/home');
});
it('prefers deepLink over category default', () => {
const item = makeSystem({ category: 'messages', deepLink: '/notifications' });
expect(resolveSystemRoute(item)).toBe('/notifications');
});
});
+98
View File
@@ -0,0 +1,98 @@
import debug from 'debug';
import type { NotificationItem } from '../store/notificationSlice';
import type { IntegrationNotification } from '../types/notifications';
const log = debug('notifications:router');
// ─────────────────────────────────────────────────────────────────────────────
// Known in-app hash routes
// ─────────────────────────────────────────────────────────────────────────────
const ROUTES = {
chat: '/chat',
skills: '/skills',
home: '/home',
notifications: '/notifications',
} as const;
/**
* Providers whose notifications belong in the unified chat / accounts view.
* Add new provider slugs here as integrations are added.
*/
const MESSAGE_PROVIDERS = new Set([
'gmail',
'slack',
'whatsapp',
'telegram',
'discord',
'linkedin',
]);
// ─────────────────────────────────────────────────────────────────────────────
// Route resolvers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Resolve a hash-router path for an integration (provider) notification.
*
* Priority:
* 1. Explicit `deep_link` set by the core triage pipeline.
* 2. Provider default — message providers → /chat.
* 3. `/notifications` fallback.
*/
export function resolveIntegrationRoute(n: IntegrationNotification): string {
if (n.deep_link) {
log('[notification-router] integration id=%s explicit deep_link=%s', n.id, n.deep_link);
return n.deep_link;
}
if (MESSAGE_PROVIDERS.has(n.provider)) {
log('[notification-router] integration id=%s provider=%s → /chat', n.id, n.provider);
return ROUTES.chat;
}
log(
'[notification-router] integration id=%s provider=%s → /notifications (fallback)',
n.id,
n.provider
);
return ROUTES.notifications;
}
/**
* Resolve a hash-router path for a system-event (`NotificationItem`) notification.
*
* Priority:
* 1. Explicit `deepLink` stored on the item.
* 2. Category default: messages/agents → /chat; skills → /skills; system → /home.
* 3. `/notifications` fallback.
*/
export function resolveSystemRoute(item: NotificationItem): string {
if (item.deepLink) {
log('[notification-router] system id=%s explicit deepLink=%s', item.id, item.deepLink);
return item.deepLink;
}
switch (item.category) {
case 'messages':
log('[notification-router] system id=%s category=messages → /chat', item.id);
return ROUTES.chat;
case 'agents':
log('[notification-router] system id=%s category=agents → /chat', item.id);
return ROUTES.chat;
case 'skills':
log('[notification-router] system id=%s category=skills → /skills', item.id);
return ROUTES.skills;
case 'system':
log('[notification-router] system id=%s category=system → /home', item.id);
return ROUTES.home;
default:
log(
'[notification-router] system id=%s category=%s → /notifications (fallback)',
item.id,
item.category
);
return ROUTES.notifications;
}
}
+9 -4
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import NotificationCenter from '../components/notifications/NotificationCenter';
import { resolveSystemRoute } from '../lib/notificationRouter';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
clearAll,
@@ -38,18 +39,22 @@ const Notifications = () => {
const handleClick = (item: NotificationItem) => {
if (!item.read) dispatch(markRead({ id: item.id }));
if (item.deepLink) navigate(item.deepLink);
navigate(resolveSystemRoute(item));
};
return (
<div className="p-4 pt-6 space-y-4">
{/* Integration notifications — from connected accounts, scored by local AI */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
<div
data-testid="integration-notifications-section"
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
<NotificationCenter />
</div>
{/* Core-bridge notifications — system events */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div
data-testid="system-events-section"
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<div>
<h1 className="text-lg font-semibold text-stone-900">System Events</h1>
@@ -80,7 +85,7 @@ const Notifications = () => {
) : (
<ul className="divide-y divide-stone-100">
{items.map(item => (
<li key={item.id}>
<li key={item.id} data-testid="notification-item">
<button
type="button"
onClick={() => handleClick(item)}
+60 -9
View File
@@ -117,6 +117,12 @@ class SocketService {
private token: string | null = null;
private mcpTransport: SocketIOMCPTransportImpl | null = null;
private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void }> = [];
// Maps original caller callbacks → wrapped callbacks so off() can locate the
// exact function references that were registered with socket.io, scoped by event.
private listenerMap = new Map<
string,
Map<(...args: unknown[]) => void, Set<(...args: unknown[]) => void>>
>();
/**
* Connect to the socket server with authentication.
@@ -268,15 +274,17 @@ class SocketService {
* Disconnect from the socket server
*/
disconnect(): void {
const uid = getSocketUserId();
if (this.socket) {
const uid = getSocketUserId();
socketLog('Disconnecting', { userId: uid });
this.socket.disconnect();
this.socket = null;
this.token = null;
this.mcpTransport = null;
store.dispatch(resetForUser({ userId: uid }));
}
this.token = null;
this.mcpTransport = null;
this.listenerMap.clear();
this.pendingListeners = [];
}
/**
@@ -320,6 +328,13 @@ class SocketService {
socketLog('Received event', { event, argsCount: args.length, hasData: args.length > 0 });
callback(...args);
};
// Track original→wrapped per event so the same callback can be used for
// multiple events without collisions.
const byEvent = this.listenerMap.get(event) ?? new Map();
const wrappedSet = byEvent.get(callback) ?? new Set();
wrappedSet.add(wrappedCallback);
byEvent.set(callback, wrappedSet);
this.listenerMap.set(event, byEvent);
if (this.socket) {
this.socket.on(event, wrappedCallback);
} else {
@@ -332,12 +347,31 @@ class SocketService {
* Remove an event listener
*/
off(event: string, callback?: (...args: unknown[]) => void): void {
if (this.socket) {
if (callback) {
this.socket.off(event, callback);
} else {
this.socket.off(event);
if (callback) {
const byEvent = this.listenerMap.get(event);
const wrappedSet = byEvent?.get(callback);
const wrappedCallbacks =
wrappedSet && wrappedSet.size > 0 ? Array.from(wrappedSet) : [callback];
const hadWrapped = !!wrappedSet && wrappedSet.size > 0;
byEvent?.delete(callback);
if (byEvent && byEvent.size === 0) {
this.listenerMap.delete(event);
}
socketLog('Removing listener', { event, hadWrappedVersion: hadWrapped });
for (const wrapped of wrappedCallbacks) {
if (this.socket) {
this.socket.off(event, wrapped);
}
// Also remove from the pending queue in case the socket isn't up yet.
this.pendingListeners = this.pendingListeners.filter(
p => !(p.event === event && p.callback === wrapped)
);
}
} else {
socketLog('Removing all listeners for event', { event });
this.socket?.off(event);
this.pendingListeners = this.pendingListeners.filter(p => p.event !== event);
this.listenerMap.delete(event);
}
}
@@ -351,8 +385,25 @@ class SocketService {
argsCount: args.length,
hasData: args.length > 0,
});
callback(...args);
try {
callback(...args);
} finally {
const byEvent = this.listenerMap.get(event);
const wrappedSet = byEvent?.get(callback);
wrappedSet?.delete(wrappedCallback);
if (wrappedSet && wrappedSet.size === 0) {
byEvent?.delete(callback);
}
if (byEvent && byEvent.size === 0) {
this.listenerMap.delete(event);
}
}
};
const byEvent = this.listenerMap.get(event) ?? new Map();
const wrappedSet = byEvent.get(callback) ?? new Set();
wrappedSet.add(wrappedCallback);
byEvent.set(callback, wrappedSet);
this.listenerMap.set(event, byEvent);
if (this.socket) {
this.socket.once(event, wrappedCallback);
} else {
@@ -27,6 +27,13 @@ describe('notificationSlice', () => {
expect(s2.items.map(i => i.id)).toEqual(['b', 'a']);
});
it('notificationReceived dedupes by id', () => {
let s = reducer(undefined, notificationReceived(makeItem({ id: 'dup', title: 'first' })));
s = reducer(s, notificationReceived(makeItem({ id: 'dup', title: 'updated' })));
expect(s.items).toHaveLength(1);
expect(s.items[0].title).toBe('updated');
});
it('drops item when its category preference is off', () => {
let s = reducer(undefined, setPreference({ category: 'messages', enabled: false }));
s = reducer(s, notificationReceived(makeItem({ id: 'a', category: 'messages' })));
@@ -4,6 +4,7 @@ import type { IntegrationNotification } from '../../types/notifications';
import notificationReducer, {
addIntegrationNotification,
dismissIntegrationNotification,
markIntegrationActed,
markIntegrationRead,
setIntegrationNotifications,
} from '../notificationSlice';
@@ -80,6 +81,41 @@ describe('notificationSlice — integration notifications', () => {
});
});
describe('markIntegrationActed', () => {
it('sets status to acted and decrements unread count for an unread notification', () => {
const n = makeNotification({ id: 'n-1', status: 'unread' });
const loaded = notificationReducer(
initialState,
setIntegrationNotifications({ items: [n], unread_count: 1 })
);
const state = notificationReducer(loaded, markIntegrationActed('n-1'));
expect(state.integrationItems[0].status).toBe('acted');
expect(state.integrationUnreadCount).toBe(0);
});
it('sets status to acted without changing unread count for a read notification', () => {
const n = makeNotification({ id: 'n-1', status: 'read' });
const loaded = notificationReducer(
initialState,
setIntegrationNotifications({ items: [n], unread_count: 0 })
);
const state = notificationReducer(loaded, markIntegrationActed('n-1'));
expect(state.integrationItems[0].status).toBe('acted');
expect(state.integrationUnreadCount).toBe(0);
});
it('is a no-op for unknown id', () => {
const n = makeNotification({ id: 'n-1', status: 'unread' });
const loaded = notificationReducer(
initialState,
setIntegrationNotifications({ items: [n], unread_count: 1 })
);
const state = notificationReducer(loaded, markIntegrationActed('does-not-exist'));
expect(state.integrationUnreadCount).toBe(1);
expect(state.integrationItems[0].status).toBe('unread');
});
});
describe('addIntegrationNotification', () => {
it('prepends a new unread notification and increments integrationUnreadCount', () => {
const n1 = makeNotification({ id: 'n-1' });
+18
View File
@@ -51,6 +51,13 @@ const notificationSlice = createSlice({
notificationReceived(state, action: PayloadAction<NotificationItem>) {
const item = action.payload;
if (!state.preferences[item.category]) return;
const existingIndex = state.items.findIndex(i => i.id === item.id);
if (existingIndex >= 0) {
// Replace existing entry in place to avoid duplicate rows when
// socket reconnects or upstream replays the same event id.
state.items[existingIndex] = item;
return;
}
state.items.unshift(item);
if (state.items.length > MAX_ITEMS) {
state.items.length = MAX_ITEMS;
@@ -95,6 +102,16 @@ const notificationSlice = createSlice({
state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1);
}
},
markIntegrationActed(state, action: PayloadAction<string>) {
const n = state.integrationItems.find(i => i.id === action.payload);
if (n) {
const wasUnread = n.status === 'unread';
n.status = 'acted';
if (wasUnread) {
state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1);
}
}
},
dismissIntegrationNotification(state, action: PayloadAction<string>) {
const n = state.integrationItems.find(i => i.id === action.payload);
if (n) {
@@ -133,6 +150,7 @@ export const {
setIntegrationError,
setIntegrationNotifications,
markIntegrationRead,
markIntegrationActed,
dismissIntegrationNotification,
addIntegrationNotification,
} = notificationSlice.actions;
+2
View File
@@ -21,6 +21,8 @@ export interface IntegrationNotification {
received_at: string;
/** ISO 8601 timestamp — undefined until triage completes */
scored_at?: string;
/** Optional in-app hash route (e.g. "/chat") set by the core triage pipeline. */
deep_link?: string;
}
export interface NotificationSettings {