PageSourceSearch

https://smrtbld.ai/js/notifications/index.js

js smrtbld.ai collected 2026-10-05 15:07:54 UTC 32,854 bytes, 885 lines download raw bytes

1/**
2 * NotificationCenter - Central Orchestrator
3 *
4 * Coordinates all notification subsystems:
5 * - NotificationStore (state management)
6 * - NotificationHubClient (SignalR real-time)
7 * - NotificationPanelController (panel chrome + UI)
8 * - NotificationItemController (item rendering + interactions)
9 * - CrossTabSync (multi-tab sync)
10 * - ActivityTracker (push notification decisions)
11 *
12 * This is the ONLY module that other parts of the app should interact with.
13 */
14
15import { pubsub } from '../utils/pubsub.js';
16import { auth } from '../auth.js';
17import * as notificationsApi from '../api/notifications.js';
18import NotificationStore from './NotificationStore.js';
19import NotificationRegistry from './NotificationRegistry.js';
20import NotificationPanelController from './NotificationPanelController.js';
21import NotificationItemController from './NotificationItemController.js';
22import CrossTabSync from './CrossTabSync.js';
23import ActivityTracker from './ActivityTracker.js';
24import {
25    getNotificationHubClient,
26    startNotificationHub,
27    stopNotificationHub,
28    isConnected as isHubConnected
29} from '../realtime/NotificationHubClient.js';
30import { initTaskGroupSync, destroyTaskGroupSync } from '../realtime/TaskGroupSync.js';
31import * as notifStorage from '../utils/localStorage-helper.js';
32import { translateLegacyLink } from '../utils/item-routes.js';
33import { downloadUrl } from '../utils/download.js';
34
35// =============================================================================
36// STATE
37// =============================================================================
38
39let isInitialized = false;
40let isUIInitialized = false;
41let panelContainer = null;
42let subscriptions = [];
43let lifecycleGeneration = 0;
44
45// =============================================================================
46// INITIALIZATION
47// =============================================================================
48
49/**
50 * Initialize the notification system
51 * Call this after authentication
52 */
53export async function init() {
54    if (isInitialized) {
55        console.warn('[NotificationCenter] Already initialized');
56        return;
57    }
58
59    const generation = ++lifecycleGeneration;
60    const user = auth.getUser();
61    if (!user?.userId) {
62        console.warn('[NotificationCenter] No authenticated user');
63        return;
64    }
65
66    console.log('[NotificationCenter] Initializing...');
67
68    // Initialize store
69    NotificationStore.init(user.userId);
70
71    // Initialize cross-tab sync
72    CrossTabSync.init();
73    // The elected leader must hear follower-only group interests even when it has no
74    // local detail/register subscription of its own.
75    initTaskGroupSync();
76
77    // Initialize activity tracker with callback
78    ActivityTracker.init({
79        onStateChange: (isTabVisible, isUserActive) => {
80            reportActivityState(isTabVisible, isUserActive);
81        }
82    });
83
84    // Subscribe to events
85    subscribeToEvents();
86
87    // Always fetch from server on init — fullSync purges stale localStorage ghosts
88    await refreshFromServer();
89    if (generation !== lifecycleGeneration) return;
90
91    // Start SignalR (only if this tab is leader — avoids duplicate connections)
92    if (CrossTabSync.isThisTabLeader()) {
93        await startSignalR();
94        if (generation !== lifecycleGeneration) {
95            await stopNotificationHub();
96            return;
97        }
98    }
99
100    isInitialized = true;
101    console.log('[NotificationCenter] Initialized');
102
103    // Emit ready event
104    pubsub.publish('notification:ready');
105}
106
107/**
108 * Initialize the UI components
109 * Call this when the app shell is rendered
110 * @param {Object} options
111 * @param {string} options.panelContainerId - ID of the panel container
112 * @param {string[]} [options.badgeSelectors] - Selectors for badge elements
113 */
114export async function initUI(options = {}) {
115    if (isUIInitialized) {
116        return;
117    }
118
119    // Initialize panel controller (internally inits item controller too)
120    await NotificationPanelController.init({
121        badgeSelectors: options.badgeSelectors || ['#notification-badge']
122    });
123
124    // Get or create panel container
125    panelContainer = document.getElementById(options.panelContainerId || 'notification-panel-container');
126    if (!panelContainer) {
127        panelContainer = document.createElement('div');
128        panelContainer.id = 'notification-panel-container';
129        document.body.appendChild(panelContainer);
130    }
131
132    // Render the panel
133    await NotificationPanelController.renderPanel(panelContainer);
134
135    // Subscribe to UI events
136    subscribeToUIEvents();
137
138    // Update badge with current count
139    NotificationPanelController.updateBadges(NotificationStore.getUnreadCount());
140
141    isUIInitialized = true;
142    console.debug('[NotificationCenter] UI initialized');
143}
144
145/**
146 * Destroy everything (on logout)
147 */
148export async function destroy() {
149    ++lifecycleGeneration;
150    // NOTE: legacy's "Clear on logout" toggle is intentionally NOT ported. Its purpose was
151    // shared-computer privacy (next user seeing the previous user's cached notifications),
152    // which this app guarantees unconditionally: NotificationStore.clear() below wipes
153    // localStorage on every sign-out, and storage.initForUser() resets state whenever a
154    // different user signs in (covers crash/expiry paths where destroy never ran).
155
156    // Unsubscribe from all events
157    subscriptions.forEach(unsub => unsub());
158    subscriptions = [];
159
160    // Stop SignalR
161    await stopNotificationHub();
162
163    // Destroy subsystems
164    destroyTaskGroupSync(); // kills the keepalive interval + group-sync pubsub subs (leaks across re-login otherwise)
165    ActivityTracker.destroy();
166    CrossTabSync.destroy();
167    NotificationPanelController.destroy();
168    NotificationStore.clear();
169
170    // Remove panel container
171    if (panelContainer) {
172        panelContainer.remove();
173        panelContainer = null;
174    }
175
176    isInitialized = false;
177    isUIInitialized = false;
178
179    console.log('[NotificationCenter] Destroyed');
180}
181
182// =============================================================================
183// EVENT SUBSCRIPTIONS
184// =============================================================================
185
186function subscribeToEvents() {
187    // SignalR events
188    subscriptions.push(
189        pubsub.subscribe('signalr:notification:new', handleSignalRNew),
190        pubsub.subscribe('signalr:notification:removed', handleSignalRRemoved),
191        pubsub.subscribe('signalr:notification:cleared', handleSignalRCleared),
192        pubsub.subscribe('signalr:notification:deliveryUpdate', handleDeliveryUpdate),
193        pubsub.subscribe('signalr:notification:forceRefresh', handleForceRefresh),
194        pubsub.subscribe('signalr:notification:detailsUpdated', handleDetailsUpdated),
195        pubsub.subscribe('signalr:notification:historyAdded', handleHistoryAdded),
196        pubsub.subscribe('signalr:notification:connected', handleSignalRConnected),
197        pubsub.subscribe('signalr:notification:reconnected', handleSignalRReconnected)
198    );
199
200    // Cross-tab events
201    subscriptions.push(
202        pubsub.subscribe('crosstab:notification:added', handleCrossTabAdded),
203        pubsub.subscribe('crosstab:notification:removed', handleCrossTabRemoved),
204        pubsub.subscribe('crosstab:notification:markedRead', handleCrossTabMarkedRead),
205        pubsub.subscribe('crosstab:notification:bulkSync', handleCrossTabBulkSync),
206        // Delivery webhooks land on the leader tab's hub only — the relay lets every
207        // tab's panel counters AND open message pages react live
208        pubsub.subscribe('crosstab:deliveryStatus', applyDeliveryUpdate),
209        // Card collapse + active tab follow across browser tabs (legacy parity,
210        // smrtbldSignalR.js:1490-1540). Receivers apply WITHOUT re-broadcasting.
211        pubsub.subscribe('crosstab:minimizeToggled', ({ threadId, isMinimized }) =>
212            NotificationStore.setMinimized(threadId, isMinimized)),
213        pubsub.subscribe('crosstab:minimizeAll', () => NotificationStore.minimizeAll()),
214        pubsub.subscribe('crosstab:expandAll', () => NotificationStore.expandAll()),
215        pubsub.subscribe('crosstab:notification:tabChanged', ({ tab }) => NotificationStore.setActiveTab(tab)),
216        // File-generation events relayed from the leader tab — UI events only, the
217        // auto-download already happened there (downloads are browser-wide)
218        pubsub.subscribe('crosstab:fileEvent', ({ kind, notification }) =>
219            pubsub.publish(kind === 'ready' ? 'notification:fileReady' : 'notification:fileFailed', notification)),
220        pubsub.subscribe('crosstab:leaderElected', handleLeaderElected),
221        pubsub.subscribe('crosstab:leaderDemoted', handleLeaderDemoted)
222    );
223}
224
225function subscribeToUIEvents() {
226    subscriptions.push(
227        pubsub.subscribe('notification:ui:click', handleUIClick),
228        pubsub.subscribe('notification:ui:close', handleUIClose),
229        pubsub.subscribe('notification:ui:toggleSticky', handleUIToggleSticky),
230        pubsub.subscribe('notification:ui:dismiss', handleUIDismiss),
231        pubsub.subscribe('notification:ui:markAllRead', handleUIMarkAllRead),
232        pubsub.subscribe('notification:ui:clearTab', handleUIClearTab),
233        pubsub.subscribe('notification:ui:toggleCollapse', handleUIToggleCollapse),
234        pubsub.subscribe('notification:ui:minimizeAll', handleUIMinimizeAll),
235        pubsub.subscribe('notification:ui:expandAll', handleUIExpandAll),
236        pubsub.subscribe('notification:ui:clearHistory', handleUIClearHistory),
237        pubsub.subscribe('notification:ui:postComment', handleUIPostComment)
238    );
239}
240
241// =============================================================================
242// SIGNALR EVENT HANDLERS
243// =============================================================================
244
245function handleSignalRNew(notification) {
246    const normalized = normalizeNotification(notification);
247
248    // Smart merge: preserve accumulated client-side state when updating an existing notification
249    const existing = NotificationStore.get(normalized.threadId);
250    if (existing) {
251        // Preserve delivery counters (SignalR push doesn't include them)
252        if (!normalized.delivery && existing.delivery) {
253            normalized.delivery = existing.delivery;
254        }
255        // Merge history: the push carries at most a seeded initialHistoryText line —
256        // prepend it onto the accumulated timeline instead of dropping either side
257        // (legacy updateOrAdd prepends, smrtbldSignalR.js:484-487)
258        if (existing.history?.length > 0) {
259            normalized.history = normalized.history?.length > 0
260                ? [...normalized.history, ...existing.history]
261                : existing.history;
262        }
263        // Preserve sticky state (user preference, not server state)
264        if (existing.isSticky) {
265            normalized.isSticky = true;
266        }
267    }
268
269    NotificationStore.upsert(normalized);
270
271    // Broadcast to other tabs
272    CrossTabSync.broadcastAdded(normalized);
273
274    // File-generation lifecycle → page-consumable events + the legacy global auto-download
275    // (smrtbldSignalR.js:2257-2290: request an export, navigate anywhere, the file still
276    // lands). Download fires here ONLY — hub events land on the leader tab once, and
277    // downloads are browser-wide; relayed tabs get just the UI events.
278    if (normalized.notificationType === 'FILE_READY') {
279        downloadUrl(normalized.dataPayload?.downloadUrl || normalized.link);
280        pubsub.publish('notification:fileReady', normalized);
281        CrossTabSync.broadcast('fileEvent', { kind: 'ready', notification: normalized });
282    } else if (normalized.notificationType === 'FILE_FAILED') {
283        pubsub.publish('notification:fileFailed', normalized);
284        CrossTabSync.broadcast('fileEvent', { kind: 'failed', notification: normalized });
285    }
286
287    // Play sound if enabled and user is active
288    if (ActivityTracker.isActivelyUsing()) {
289        playNotificationSound();
290    }
291}
292
293/**
294 * addHistoryToNotification — incremental history append without a full refresh.
295 * Legacy contract (smrtbldSignalR.js:1802-1804): { threadId, historyText, isRead }.
296 */
297function handleHistoryAdded(data) {
298    const threadId = data.threadId ?? data.ThreadId;
299    const text = data.historyText ?? data.HistoryText;
300    if (!threadId || !text) return;
301
302    const updated = NotificationStore.addHistory(threadId, {
303        text,
304        isRead: data.isRead ?? data.IsRead ?? false
305    });
306
307    if (updated) {
308        CrossTabSync.broadcastAdded(updated);
309        if (ActivityTracker.isActivelyUsing()) {
310            playNotificationSound();
311        }
312    }
313}
314
315function handleSignalRRemoved({ threadId }) {
316    NotificationStore.remove(threadId, false);
317    CrossTabSync.broadcastRemoved(threadId);
318}
319
320function handleSignalRCleared(data) {
321    // Full refresh - server cleared notifications
322    refreshFromServer();
323}
324
325function handleDeliveryUpdate(data) {
326    applyDeliveryUpdate(data);
327    // Relay to other tabs (received there as crosstab:deliveryStatus — no re-relay)
328    CrossTabSync.broadcast('deliveryStatus', data);
329}
330
331function applyDeliveryUpdate(data) {
332    // onDeliveryStatusUpdated sends per-recipient data, not aggregate.
333    // Build the threadId — could be msg-{parentEmailId} or msg-{projectEmailId}
334    const threadKey = data.threadId
335        || (data.parentEmailId ? `msg-${data.parentEmailId}` : null)
336        || (data.projectEmailId ? `msg-${data.projectEmailId}` : null);
337
338    if (!threadKey) return;
339
340    const notif = NotificationStore.get(threadKey);
341    if (!notif) return;
342
343    // Only accumulate if delivery tracking is already initialized with a known total.
344    // If total is unknown (0), skip — onNotificationDetailsUpdated will provide the
345    // authoritative aggregate with correct total/delivered/failed counts.
346    if (!notif.delivery || notif.delivery.total <= 0) return;
347
348    // Accumulate per-recipient status
349    const status = (data.newStatus || '').toLowerCase();
350    if (status === 'delivered') {
351        notif.delivery.delivered = Math.min(notif.delivery.delivered + 1, notif.delivery.total);
352    } else if (status === 'failed' && (data.severity || '').toLowerCase() === 'permanent') {
353        notif.delivery.failed = Math.min(notif.delivery.failed + 1, notif.delivery.total - notif.delivery.delivered);
354    }
355
356    NotificationStore.upsert(notif);
357}
358
359function handleForceRefresh() {
360    refreshFromServer();
361}
362
363function handleDetailsUpdated(data) {
364    // Targeted upsert from onNotificationDetailsUpdated — avoids full refresh
365    const fullNotif = data.fullNotificationData;
366    if (fullNotif) {
367        const normalized = normalizeNotification(fullNotif);
368
369        // Legacy un-hide (smrtbldSignalR.js:1825-1832): new activity on a dismissed
370        // assignment thread resurfaces it — the backend mirrors this with IsCleared = 0.
371        const threadId = normalized.threadId || '';
372        if (threadId.startsWith('asgn-') || threadId.startsWith('calagsn-')) {
373            NotificationStore.unremove(threadId);
374        }
375
376        NotificationStore.upsert(normalized);
377        CrossTabSync.broadcastAdded(normalized);
378    }
379}
380
381function handleSignalRConnected() {
382    // Acknowledge and fetch
383    acknowledgeNotifications('signalr');
384}
385
386function handleSignalRReconnected() {
387    // Re-fetch on reconnect
388    refreshFromServer();
389    ActivityTracker.forceReport();
390}
391
392// =============================================================================
393// CROSS-TAB EVENT HANDLERS
394// =============================================================================
395
396function handleCrossTabAdded(notification) {
397    // Another tab added a notification - already normalized by the leader tab
398    NotificationStore.upsert(notification);
399}
400
401function handleCrossTabRemoved({ threadId }) {
402    NotificationStore.remove(threadId, false);
403}
404
405function handleCrossTabMarkedRead({ threadIds }) {
406    NotificationStore.markAsRead(threadIds);
407}
408
409function handleCrossTabBulkSync({ notifications }) {
410    NotificationStore.bulkUpdate(notifications);
411}
412
413function handleLeaderElected({ isThisTab }) {
414    if (isThisTab) {
415        // We became the leader - start SignalR
416        console.log('[NotificationCenter] This tab is now the leader');
417        startSignalR();
418    }
419}
420
421// Demoted (fallback election or a freeze hand-off): stop the hub, keep every subscription (CA-548).
422function handleLeaderDemoted() {
423    console.log('[NotificationCenter] This tab is no longer the leader');
424    stopNotificationHub();
425}
426
427// =============================================================================
428// UI EVENT HANDLERS
429// =============================================================================
430
431function handleUIClick({ threadId }) {
432    const notif = NotificationStore.get(threadId);
433    if (!notif) return;
434
435    // Mark as read
436    markAsRead(threadId);
437
438    // Close panel only if NOT pinned — pinned panel stays open during navigation
439    if (!NotificationStore.isPanelPinned()) {
440        NotificationPanelController.hidePanel();
441    }
442
443    // Navigate using registry click handler
444    NotificationRegistry.handleClick(notif, window.page);
445}
446
447function handleUIClose() {
448    NotificationPanelController.hidePanel();
449}
450
451async function handleUIToggleSticky({ threadId }) {
452    try {
453        // REST — the hub never exposed a ToggleSticky method; the old invoke() always
454        // threw and silently fell back here.
455        await notificationsApi.toggleSticky(threadId);
456
457        // Optimistic update (server will confirm via SignalR)
458        NotificationStore.toggleSticky(threadId);
459    } catch (e) {
460        console.error('[NotificationCenter] Toggle sticky failed:', e);
461        window.showToast?.('Failed to pin notification', 'error', { description: e.message });
462    }
463}
464
465async function handleUIDismiss({ threadId }) {
466    try {
467        // REST — soft-clear on the server (legacy ClearNotification; resurfaces on new activity)
468        await notificationsApi.deleteNotification(threadId);
469
470        // Remove from store (mark as user-dismissed)
471        NotificationStore.remove(threadId, true);
472
473        // Broadcast to other tabs
474        CrossTabSync.broadcastRemoved(threadId);
475    } catch (e) {
476        console.error('[NotificationCenter] Dismiss failed:', e);
477        window.showToast?.('Failed to dismiss notification', 'error', { description: e.message });
478    }
479}
480
481async function handleUIMarkAllRead() {
482    const notifications = NotificationStore.getFiltered();
483    const unreadIds = notifications
484        .filter(n => n.hasUnread)
485        .map(n => n.threadId);
486
487    if (unreadIds.length === 0) return;
488
489    try {
490        await markAsRead(unreadIds);
491    } catch (e) {
492        console.error('[NotificationCenter] Mark all read failed:', e);
493        window.showToast?.('Failed to mark notifications as read', 'error', { description: e.message });
494    }
495}
496
497async function handleUIClearTab({ tab, includeSticky = false }) {
498    try {
499        // Get items to clear before removing from store
500        const filtered = NotificationStore.getFiltered();
501        const itemsToClear = includeSticky
502            ? filtered
503            : filtered.filter(n => !n.isSticky);
504
505        if (itemsToClear.length === 0) return;
506
507        // Animate items out gracefully
508        const container = document.getElementById('notification-list');
509        if (container) {
510            await NotificationItemController.animateItemsOut(
511                container,
512                itemsToClear.map(n => n.threadId)
513            );
514        }
515
516        // Clear from store (batch operation)
517        const { removedIds } = NotificationStore.clearFiltered(includeSticky);
518
519        // Broadcast removals to other tabs
520        for (const threadId of removedIds) {
521            CrossTabSync.broadcastRemoved(threadId);
522        }
523
524        // Server-side clear is the batch endpoint (legacy ClearNotificationsForTab —
525        // hard-deletes non-sticky rows for the tab's app names in one statement)
526        notificationsApi.clearByTab(tab).catch(() => {});
527
528        const tabInfo = NotificationRegistry.getTabDisplayInfo(tab);
529        window.showToast?.(`Cleared ${removedIds.length} ${tabInfo.noun}`, 'success');
530    } catch (e) {
531        console.error('[NotificationCenter] Clear tab failed:', e);
532        window.showToast?.('Failed to clear notifications', 'error', { description: e.message });
533    }
534}
535
536function handleUIToggleCollapse({ threadId }) {
537    const isMinimized = NotificationStore.toggleMinimized(threadId);
538    // Legacy synced card collapse across browser tabs (smrtbldSignalR.js:1490/1532)
539    CrossTabSync.broadcast('minimizeToggled', { threadId, isMinimized });
540}
541
542function handleUIMinimizeAll() {
543    NotificationStore.minimizeAll();
544    CrossTabSync.broadcast('minimizeAll', {});
545}
546
547function handleUIExpandAll() {
548    NotificationStore.expandAll();
549    CrossTabSync.broadcast('expandAll', {});
550}
551
552async function handleUIClearHistory({ threadId }) {
553    try {
554        await notificationsApi.clearHistory(threadId);
555        NotificationStore.clearHistory(threadId);
556    } catch (e) {
557        console.error('[NotificationCenter] Clear history failed:', e);
558    }
559}
560
561async function handleUIPostComment({ threadId, text, onDone }) {
562    try {
563        // The comment lands on the underlying task/calendar event; the server then
564        // broadcasts onNotificationDetailsUpdated with the refreshed payload, which
565        // re-renders the card — exact legacy flow (smrtbldSignalR.js:2132-2167).
566        // No local store mutation here.
567        await notificationsApi.postComment(threadId, text);
568        window.showToast?.('Comment posted', 'success');
569        onDone?.(true);
570    } catch (e) {
571        console.error('[NotificationCenter] Post comment failed:', e);
572        window.showToast?.('Failed to post comment', 'error', { description: e.message });
573        onDone?.(false);
574    }
575}
576
577// =============================================================================
578// PUBLIC API
579// =============================================================================
580
581/**
582 * Toggle panel visibility
583 */
584export function togglePanel() {
585    if (NotificationPanelController.isPanelVisible()) {
586        NotificationPanelController.hidePanel();
587    } else {
588        NotificationPanelController.showPanel();
589    }
590}
591
592/**
593 * Show the panel
594 */
595export function showPanel() {
596    NotificationPanelController.showPanel();
597}
598
599/**
600 * Hide the panel
601 */
602export function hidePanel() {
603    NotificationPanelController.hidePanel();
604}
605
606/**
607 * Mark notification(s) as read
608 * @param {string|string[]} threadIds
609 */
610export async function markAsRead(threadIds) {
611    const ids = Array.isArray(threadIds) ? threadIds : [threadIds];
612
613    // Optimistic update
614    NotificationStore.markAsRead(ids);
615
616    // Broadcast to other tabs
617    CrossTabSync.broadcastMarkedRead(ids);
618
619    // Send to server (REST — marks the NotificationHistory rows read)
620    try {
621        await notificationsApi.markAsRead(ids);
622    } catch (e) {
623        console.error('[NotificationCenter] markAsRead failed:', e);
624    }
625}
626
627/**
628 * Get unread count
629 * @returns {number}
630 */
631export function getUnreadCount() {
632    return NotificationStore.getUnreadCount();
633}
634
635/**
636 * Get unread counts by tab
637 * @returns {Object}
638 */
639export function getUnreadByTab() {
640    return NotificationStore.getUnreadByTab();
641}
642
643/**
644 * Refresh notifications from server.
645 * @param {Object} [options]
646 * @param {boolean} [options.leaderOnly=false] - If true, skip fetch when not leader tab
647 */
648export async function refreshFromServer({ leaderOnly = false } = {}) {
649    if (leaderOnly && !CrossTabSync.isThisTabLeader()) {
650        return;
651    }
652
653    try {
654        const response = await notificationsApi.getMyNotifications();
655        // Feed returns { notifications: [...], unreadCount, unreadByApp } — extract the array
656        const list = Array.isArray(response) ? response : (response.notifications ?? []);
657        const notifications = list.map(normalizeNotification);
658        NotificationStore.bulkUpdate(notifications, { fullSync: true });
659
660        // Broadcast to other tabs (only if leader, to avoid duplicate broadcasts)
661        if (CrossTabSync.isThisTabLeader()) {
662            CrossTabSync.broadcastBulkSync(notifications);
663        }
664    } catch (e) {
665        console.error('[NotificationCenter] Failed to fetch notifications:', e);
666    }
667}
668
669// =============================================================================
670// NOTIFICATION NORMALIZATION
671// =============================================================================
672
673/**
674 * Normalize notification payloads from any source into the canonical store shape.
675 *
676 * Two sources send different shapes:
677 * - SignalR push (NotificationPushPayload): nested, uses "type" field
678 * - REST feed (NotificationResponse): flat, uses "notificationType" field
679 *
680 * This function maps both to the flat shape the Store/Registry/Renderer expect.
681 */
682function normalizeNotification(raw) {
683    if (!raw) return raw;
684
685    // Detect SignalR push shape: has nested `data` object with title/Title inside
686    const d = raw.data;
687    const isSignalRPush = d && typeof d === 'object'
688        && ('title' in d || 'Title' in d || 'link' in d || 'Link' in d);
689
690    let result;
691
692    if (isSignalRPush) {
693        const isRead = d.isRead ?? d.IsRead ?? true;
694        const historyText = d.initialHistoryText ?? d.InitialHistoryText ?? '';
695        result = {
696            threadId:         raw.threadId,
697            appName:          raw.appName,
698            appIcon:          raw.appIcon,
699            notificationType: raw.type,                             // "type" → "notificationType"
700            title:            d.title ?? d.Title ?? '',
701            subtitle:         d.subtitle ?? d.Subtitle ?? '',
702            // Legacy-shaped /project/... links (shared-table contract) → new-app routes.
703            link:             translateLegacyLink(d.link ?? d.Link ?? ''),
704            hasUnread:        !isRead,
705            isSticky:         false,
706            lastUpdate:       new Date().toISOString(),
707            projectId:        raw.projectId,
708            detailsPayload:   d.details ?? d.Details ?? null,
709            dataPayload:      d.payload ?? d.Payload ?? null,
710            // Seed the timeline from initialHistoryText so the new line renders before the
711            // feed fills full history in (legacy smrtbldSignalR.js:484-487). The DB row is
712            // written server-side; handleSignalRNew merges this onto any existing history.
713            history:          historyText
714                ? [{ text: historyText, isRead, createdAt: new Date().toISOString() }]
715                : [],
716        };
717    } else {
718        // REST feed shape — already mostly flat after ApiClient camel
718Case transform
719        const history = raw.history ?? [];
720        result = {
721            threadId:         raw.threadId,
722            appName:          raw.appName,
723            appIcon:          raw.appIcon,
724            notificationType: raw.notificationType,
725            title:            raw.title ?? '',
726            subtitle:         raw.subtitle ?? '',
727            link:             translateLegacyLink(raw.link ?? ''),
728            // Read state derives from history rows when present (legacy contract);
729            // the Notifications.IsRead flag is only the fallback for history-less rows
730            hasUnread:        history.length > 0
731                ? history.some(h => !h.isRead)
732                : !(raw.isRead ?? true),
733            isSticky:         raw.isSticky ?? false,
734            // Newest history line drives the card's "last activity" time (legacy :413);
735            // history arrives ordered newest-first
736            lastUpdate:       history[0]?.createdAt ?? raw.modifiedAt ?? raw.createdAt ?? new Date().toISOString(),
737            projectId:        raw.projectId,
738            projectName:      raw.projectName,
739            detailsPayload:   raw.detailsPayload ?? null,
740            dataPayload:      raw.data ?? null,
741            history,
742            delivery:         raw.delivery ?? null,
743        };
744    }
745
746    // Accept delivery aggregate directly if present (deep transform makes it camelCase)
747    if (isSignalRPush && d?.delivery) {
748        result.delivery = d.delivery;
749    }
750
751    // Seed delivery counters for USER_SENT notifications (fallback if no aggregate)
752    if (result.notificationType === 'USER_SENT' && !result.delivery) {
753        const dp = result.dataPayload;
754        const det = result.detailsPayload;
755        // Backend sends count under various field names — check all known variants
756        const count = dp?.recipientCount ?? dp?.recipient_Count
757                   ?? dp?.participantCount ?? dp?.participant_Count
758                   ?? det?.recipientCount ?? det?.recipient_Count
759                   ?? det?.participantCount ?? det?.participant_Count
760                   ?? 0;
761        if (count > 0) {
762            result.delivery = {
763                total: count,
764                delivered: 0,
765                failed: 0,
766                // The send these counters track (USER_SENT DataPayload carries it) — lets
767                // the merge recognize a NEW delivery cycle vs cached previous-send counters
768                projectEmailId: dp?.projectEmailId ?? null
769            };
770        }
771    }
772
773    return result;
774}
775
776// =============================================================================
777// INTERNAL HELPERS
778// =============================================================================
779
780async function startSignalR() {
781    const hubClient = getNotificationHubClient();
782
783    // Set activity report callback
784    hubClient.setActivityReportCallback(() => {
785        const state = ActivityTracker.getState();
786        hubClient.reportActivityState(state.isTabVisible, state.isUserActive);
787    });
788
789    await startNotificationHub();
790}
791
792function reportActivityState(isTabVisible, isUserActive) {
793    if (!isHubConnected()) return;
794
795    const hubClient = getNotificationHubClient();
796    hubClient.reportActivityState(isTabVisible, isUserActive);
797}
798
799async function acknowledgeNotifications(via) {
800    try {
801        await notificationsApi.acknowledgeNotifications(via);
802    } catch (e) {
803        // Non-critical
804        console.debug('[NotificationCenter] Acknowledge failed:', e.message);
805    }
806}
807
808/**
809 * Play a subtle notification chime using Web Audio API.
810 * No audio file required — generates a clean two-tone programmatically.
811 */
812let _audioCtx = null;
813let _lastSoundTime = 0;
814const SOUND_COOLDOWN_MS = 2000; // Don't spam sounds for rapid-fire notifications
815
816function playNotificationSound() {
817    // User preference gate (settings page sound toggle) — read via the shared storage
818    // helper so there's exactly one parser for the sb_notif schema
819    if (notifStorage.getSettings().soundEnabled === false) return;
820
821    // Throttle: skip if we just played one
822    const now = Date.now();
823    if (now - _lastSoundTime < SOUND_COOLDOWN_MS) return;
824    _lastSoundTime = now;
825
826    try {
827        if (!_audioCtx) {
828            _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
829        }
830
831        const ctx = _audioCtx;
832
833        // Two-tone chime: E5 → G5 (pleasant, non-intrusive)
834        const frequencies = [659.25, 783.99];
835        const startTime = ctx.currentTime;
836
837        frequencies.forEach((freq, i) => {
838            const oscillator = ctx.createOscillator();
839            const gainNode = ctx.createGain();
840
841            oscillator.type = 'sine';
842            oscillator.frequency.value = freq;
843
844            // Gentle volume envelope
845            gainNode.gain.setValueAtTime(0, startTime + i * 0.12);
846            gainNode.gain.linearRampToValueAtTime(0.15, startTime + i * 0.12 + 0.02);
847            gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + i * 0.12 + 0.25);
848
849            oscillator.connect(gainNode);
850            gainNode.connect(ctx.destination);
851
852            oscillator.start(startTime + i * 0.12);
853            oscillator.stop(startTime + i * 0.12 + 0.3);
854        });
855    } catch (e) {
856        // AudioContext may be blocked by browser policy — silently ignore
857        console.debug('[NotificationCenter] Sound playback failed:', e.message);
858    }
859}
860
861// =============================================================================
862// EXPORTS
863// =============================================================================
864
865export default {
866    init,
867    initUI,
868    destroy,
869    togglePanel,
870    showPanel,
871    hidePanel,
872    markAsRead,
873    getUnreadCount,
874    getUnreadByTab,
875    refreshFromServer
876};
877
878// Re-export subsystems for advanced use cases
879export {
880    NotificationStore,
881    NotificationRegistry,
882    NotificationPanelController,
883    CrossTabSync,
884    ActivityTracker
885};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.