mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(notifications): request OS permission at startup and fix socketService listener leak (#998)
This commit is contained in:
@@ -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,2 +1,2 @@
|
||||
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
|
||||
export { showNativeNotification } from './tauriBridge';
|
||||
export { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user