mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
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,
|
|
setIntegrationNotifications,
|
|
} from '../../store/notificationSlice';
|
|
import CoreNotificationCard from './CoreNotificationCard';
|
|
import NotificationCard from './NotificationCard';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Component
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const NotificationCenter = () => {
|
|
const { t } = useT();
|
|
const dispatch = useAppDispatch();
|
|
const navigate = useNavigate();
|
|
const {
|
|
integrationItems: items,
|
|
integrationLoading: loading,
|
|
integrationError: error,
|
|
items: coreItems,
|
|
} = useAppSelector(s => s.notifications);
|
|
// Core-originated notifications that carry action buttons (e.g. the calendar
|
|
// auto-join prompt, issue #3507). These live in a separate Redux array from
|
|
// the server-fetched integration notifications and are surfaced at the top.
|
|
const coreActionItems = coreItems.filter(i => i.actions && i.actions.length > 0);
|
|
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
|
|
// All providers seen across unfiltered loads — kept separate so the filter
|
|
// pill row doesn't collapse when a provider filter is active.
|
|
const [allProviders, setAllProviders] = useState<string[]>([]);
|
|
const visibleItems = items.filter(
|
|
n => n.status !== 'dismissed' && (!selectedProvider || n.provider === selectedProvider)
|
|
);
|
|
|
|
// Fetch on mount and when provider filter changes.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
dispatch(setIntegrationLoading(true));
|
|
try {
|
|
const result = await fetchNotifications({ provider: selectedProvider, limit: 100 });
|
|
if (!cancelled) {
|
|
dispatch(setIntegrationNotifications(result));
|
|
// Accumulate providers only from unfiltered loads so the pill row
|
|
// stays stable when a filter is active.
|
|
if (!selectedProvider) {
|
|
const seen = Array.from(new Set(result.items.map(n => n.provider))).sort();
|
|
setAllProviders(seen);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
dispatch(
|
|
setIntegrationError(err instanceof Error ? err.message : 'Failed to load notifications')
|
|
);
|
|
}
|
|
}
|
|
};
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [dispatch, selectedProvider]);
|
|
|
|
const handleMarkRead = async (id: string) => {
|
|
dispatch(markIntegrationRead(id));
|
|
try {
|
|
await markNotificationRead(id);
|
|
} catch {
|
|
// Optimistic update already applied; log failure silently.
|
|
}
|
|
};
|
|
|
|
/** 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 {
|
|
await dismissNotification(id);
|
|
} catch {
|
|
// Optimistic update applied; failure is silent.
|
|
}
|
|
};
|
|
|
|
// Unread count scoped to the currently displayed (filtered) items.
|
|
const filteredUnreadCount = visibleItems.filter(n => n.status === 'unread').length;
|
|
|
|
const handleMarkAllRead = async () => {
|
|
const unreadIds = visibleItems.filter(n => n.status === 'unread').map(n => n.id);
|
|
for (const id of unreadIds) {
|
|
dispatch(markIntegrationRead(id));
|
|
try {
|
|
await markNotificationRead(id);
|
|
} catch {
|
|
// Ignore individual failures.
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-200 dark:border-neutral-800">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
|
{t('notifications.center.title')}
|
|
</h2>
|
|
{filteredUnreadCount > 0 && (
|
|
<span className="px-1.5 py-0.5 rounded-full text-[11px] font-semibold bg-primary-500 text-white">
|
|
{filteredUnreadCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{filteredUnreadCount > 0 && (
|
|
<button
|
|
onClick={() => {
|
|
void handleMarkAllRead();
|
|
}}
|
|
className="text-xs text-primary-600 hover:text-primary-700 font-medium transition-colors">
|
|
{t('notifications.center.markAllRead')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider filter pills */}
|
|
{allProviders.length > 1 && (
|
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-stone-100 dark:border-neutral-800 overflow-x-auto">
|
|
<button
|
|
onClick={() => setSelectedProvider(undefined)}
|
|
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
selectedProvider === undefined
|
|
? 'bg-primary-500 text-white'
|
|
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-800/60'
|
|
}`}>
|
|
{t('notifications.center.filterAll')}
|
|
</button>
|
|
{allProviders.map(p => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setSelectedProvider(p === selectedProvider ? undefined : p)}
|
|
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
selectedProvider === p
|
|
? 'bg-primary-500 text-white'
|
|
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-800/60'
|
|
}`}>
|
|
{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Actionable core notifications (e.g. meeting auto-join prompt) —
|
|
always shown first, independent of integration load state. */}
|
|
{coreActionItems.length > 0 && (
|
|
<div className="divide-y-0">
|
|
{coreActionItems.map(item => (
|
|
<CoreNotificationCard key={item.id} notification={item} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500 text-sm">
|
|
{t('common.loading')}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && error && (
|
|
<div className="m-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && visibleItems.length === 0 && coreActionItems.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-16 text-stone-400 dark:text-neutral-500">
|
|
<svg
|
|
className="w-10 h-10 mb-3 opacity-40"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm font-medium">{t('notifications.center.empty')}</p>
|
|
<p className="text-xs mt-1 opacity-70">{t('notifications.center.emptyHint')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && visibleItems.length > 0 && (
|
|
<div className="divide-y-0">
|
|
{visibleItems.map(n => (
|
|
<NotificationCard
|
|
key={n.id}
|
|
notification={n}
|
|
onMarkRead={id => {
|
|
void handleMarkRead(id);
|
|
}}
|
|
onNavigate={id => {
|
|
void handleNavigate(id);
|
|
}}
|
|
onDismiss={id => {
|
|
void handleDismiss(id);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NotificationCenter;
|