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.