Spaces:
Sleeping
Sleeping
| /** | |
| * MorphGuard Notification Client | |
| * | |
| * Provides client-side integration with the server's notification system, | |
| * including intelligent notification handling, priority management, | |
| * context-aware display, and cognitive load management. | |
| */ | |
| class NotificationClient { | |
| /** | |
| * Initialize the notification client | |
| * @param {Object} options - Configuration options | |
| */ | |
| constructor(options = {}) { | |
| // Default options | |
| this.options = { | |
| pollInterval: 10000, // 10 seconds | |
| maxNotifications: 5, // Maximum number of notifications to show at once | |
| autoHideDelay: 5000, // Auto-hide delay for toast notifications (ms) | |
| notificationContainer: '#notification-container', | |
| toastContainer: '#toast-container', | |
| modalContainer: '#modal-container', | |
| enablePolling: true, | |
| enableWebSocket: true, | |
| debug: false, | |
| onNotificationReceived: null, // Callback for new notifications | |
| onNotificationRead: null, // Callback for read notifications | |
| serverUrl: '/api/notifications', | |
| userPreferences: null, // User notification preferences | |
| ...options | |
| }; | |
| // Notification state | |
| this.notifications = []; | |
| this.unreadCount = 0; | |
| this.activeNotifications = new Set(); | |
| this.lastPollTime = 0; | |
| this.isPolling = false; | |
| this.webSocket = null; | |
| this.isWebSocketConnected = false; | |
| this.interactionHistory = []; | |
| this.cognitiveLoadEstimate = 0.0; | |
| this.attentionScore = 1.0; | |
| this.throttleTimers = {}; | |
| // Session context | |
| this.sessionContext = { | |
| startTime: Date.now(), | |
| lastActivity: Date.now(), | |
| pageTransitions: 0, | |
| formInteractions: 0, | |
| scrollEvents: 0, | |
| clickEvents: 0, | |
| keyboardEvents: 0, | |
| errors: 0, | |
| activeModals: 0, | |
| isActive: true, | |
| timeOnPage: 0 | |
| }; | |
| // Initialize | |
| this.init(); | |
| } | |
| /** | |
| * Initialize the notification client | |
| */ | |
| init() { | |
| // Create notification containers if they don't exist | |
| this.createContainers(); | |
| // Set up event listeners | |
| this.setupEventListeners(); | |
| // Initialize WebSocket if enabled | |
| if (this.options.enableWebSocket) { | |
| this.connectWebSocket(); | |
| } | |
| // Start polling for notifications | |
| if (this.options.enablePolling) { | |
| this.startPolling(); | |
| } | |
| // Log initialization | |
| this.log('NotificationClient initialized'); | |
| } | |
| /** | |
| * Create notification containers if they don't exist | |
| */ | |
| createContainers() { | |
| const createContainer = (selector, className) => { | |
| if (!document.querySelector(selector)) { | |
| const container = document.createElement('div'); | |
| container.id = selector.substring(1); // Remove the # from the selector | |
| container.className = className; | |
| document.body.appendChild(container); | |
| } | |
| }; | |
| createContainer(this.options.notificationContainer, 'mg-notification-container'); | |
| createContainer(this.options.toastContainer, 'mg-toast-container'); | |
| createContainer(this.options.modalContainer, 'mg-modal-container'); | |
| } | |
| /** | |
| * Set up event listeners | |
| */ | |
| setupEventListeners() { | |
| // Track user activity | |
| document.addEventListener('click', e => this.trackActivity('click', e)); | |
| document.addEventListener('keydown', e => this.trackActivity('keydown', e)); | |
| document.addEventListener('scroll', e => this.trackActivity('scroll', e)); | |
| document.addEventListener('mousemove', e => this.throttle('mousemove', () => this.trackActivity('mousemove'), 1000)); | |
| // Track form interactions | |
| document.addEventListener('submit', e => this.trackActivity('form_submit', e)); | |
| document.addEventListener('change', e => this.trackActivity('form_change', e)); | |
| // Track page visibility | |
| document.addEventListener('visibilitychange', () => this.handleVisibilityChange()); | |
| // Track page navigation | |
| window.addEventListener('popstate', () => this.trackActivity('navigation')); | |
| // Track errors | |
| window.addEventListener('error', e => this.trackActivity('error', e)); | |
| // Set up periodic context updates | |
| setInterval(() => this.updateContext(), 5000); | |
| } | |
| /** | |
| * Throttle an event callback | |
| * @param {string} key - Throttle key | |
| * @param {Function} callback - Function to throttle | |
| * @param {number} limit - Throttle time in ms | |
| */ | |
| throttle(key, callback, limit) { | |
| if (!this.throttleTimers[key]) { | |
| callback(); | |
| this.throttleTimers[key] = setTimeout(() => { | |
| delete this.throttleTimers[key]; | |
| }, limit); | |
| } | |
| } | |
| /** | |
| * Track user activity | |
| * @param {string} type - Activity type | |
| * @param {Event} event - Browser event | |
| */ | |
| trackActivity(type, event) { | |
| const now = Date.now(); | |
| this.sessionContext.lastActivity = now; | |
| this.sessionContext.isActive = true; | |
| // Update specific activity counters | |
| switch (type) { | |
| case 'click': | |
| this.sessionContext.clickEvents++; | |
| break; | |
| case 'keydown': | |
| this.sessionContext.keyboardEvents++; | |
| break; | |
| case 'scroll': | |
| this.sessionContext.scrollEvents++; | |
| break; | |
| case 'navigation': | |
| this.sessionContext.pageTransitions++; | |
| break; | |
| case 'form_submit': | |
| case 'form_change': | |
| this.sessionContext.formInteractions++; | |
| break; | |
| case 'error': | |
| this.sessionContext.errors++; | |
| break; | |
| } | |
| // Record in interaction history (limit to last 50 events) | |
| this.interactionHistory.push({ | |
| type, | |
| timestamp: now, | |
| data: event ? this.sanitizeEventData(event) : null | |
| }); | |
| if (this.interactionHistory.length > 50) { | |
| this.interactionHistory.shift(); | |
| } | |
| // Update cognitive load estimate | |
| this.updateCognitiveLoadEstimate(); | |
| } | |
| /** | |
| * Sanitize event data for storage | |
| * @param {Event} event - Browser event | |
| * @returns {Object} Sanitized event data | |
| */ | |
| sanitizeEventData(event) { | |
| // Extract only needed data to avoid circular references | |
| if (!event) return null; | |
| const data = {}; | |
| if (event.target) { | |
| data.target = { | |
| tagName: event.target.tagName, | |
| id: event.target.id, | |
| className: event.target.className, | |
| type: event.target.type | |
| }; | |
| } | |
| if (event.key) { | |
| data.key = event.key; | |
| } | |
| return data; | |
| } | |
| /** | |
| * Handle visibility change events | |
| */ | |
| handleVisibilityChange() { | |
| if (document.visibilityState === 'visible') { | |
| this.sessionContext.isActive = true; | |
| this.sessionContext.lastActivity = Date.now(); | |
| // Refresh notifications when tab becomes visible | |
| this.fetchNotifications(); | |
| } else { | |
| this.sessionContext.isActive = false; | |
| } | |
| } | |
| /** | |
| * Update the session context | |
| */ | |
| updateContext() { | |
| const now = Date.now(); | |
| // Update time on page | |
| this.sessionContext.timeOnPage = Math.floor((now - this.sessionContext.startTime) / 1000); | |
| // Check if user is still active (inactive after 60 seconds) | |
| if (now - this.sessionContext.lastActivity > 60000) { | |
| this.sessionContext.isActive = false; | |
| } | |
| // Calculate attention score based on recent activity | |
| this.updateAttentionScore(); | |
| } | |
| /** | |
| * Update cognitive load estimate based on recent activity | |
| */ | |
| updateCognitiveLoadEstimate() { | |
| // Simple cognitive load model based on: | |
| // 1. Number of active notifications | |
| // 2. Form interaction complexity | |
| // 3. Error rate | |
| // 4. Page transition frequency | |
| // 5. Input frequency | |
| const activeNotificationLoad = Math.min(this.activeNotifications.size / 5, 1) * 0.3; | |
| // Calculate input frequency (events in the last 30 seconds) | |
| const recentEvents = this.interactionHistory.filter(e => | |
| Date.now() - e.timestamp < 30000 | |
| ).length; | |
| const inputLoad = Math.min(recentEvents / 20, 1) * 0.2; | |
| // Calculate error impact | |
| const errorLoad = Math.min(this.sessionContext.errors / 5, 1) * 0.3; | |
| // Calculate form complexity | |
| const formLoad = Math.min(this.sessionContext.formInteractions / 10, 1) * 0.1; | |
| // Calculate page transition frequency | |
| const pageTransitionRate = this.sessionContext.pageTransitions / | |
| (Math.max(this.sessionContext.timeOnPage, 1) / 60); // transitions per minute | |
| const navigationLoad = Math.min(pageTransitionRate / 3, 1) * 0.1; | |
| // Calculate combined load (0-1 scale) | |
| this.cognitiveLoadEstimate = Math.min( | |
| activeNotificationLoad + inputLoad + errorLoad + formLoad + navigationLoad, | |
| 1.0 | |
| ); | |
| this.log(`Cognitive load estimate updated: ${this.cognitiveLoadEstimate.toFixed(2)}`); | |
| } | |
| /** | |
| * Update attention score based on recent activity | |
| */ | |
| updateAttentionScore() { | |
| // Simple attention model based on: | |
| // 1. Time since last activity | |
| // 2. Recent input frequency | |
| // 3. Page visibility | |
| // Calculate time factor (attention decreases with inactivity) | |
| const timeSinceActivity = (Date.now() - this.sessionContext.lastActivity) / 1000; // seconds | |
| const timeFactor = Math.max(1 - (timeSinceActivity / 60), 0); // 0 after 60 seconds of inactivity | |
| // Calculate activity factor (recent events indicate attention) | |
| const recentEvents = this.interactionHistory.filter(e => | |
| Date.now() - e.timestamp < 10000 // events in last 10 seconds | |
| ).length; | |
| const activityFactor = Math.min(recentEvents / 5, 1); | |
| // Page visibility factor | |
| const visibilityFactor = document.visibilityState === 'visible' ? 1 : 0; | |
| // Combined attention score (0-1 scale) | |
| this.attentionScore = Math.min( | |
| (timeFactor * 0.4) + (activityFactor * 0.3) + (visibilityFactor * 0.3), | |
| 1.0 | |
| ); | |
| this.log(`Attention score updated: ${this.attentionScore.toFixed(2)}`); | |
| } | |
| /** | |
| * Connect to WebSocket for real-time notifications | |
| */ | |
| connectWebSocket() { | |
| try { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/notifications`; | |
| this.webSocket = new WebSocket(wsUrl); | |
| this.webSocket.onopen = () => { | |
| this.isWebSocketConnected = true; | |
| this.log('WebSocket connected'); | |
| }; | |
| this.webSocket.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === 'notification') { | |
| this.handleNewNotification(data.notification); | |
| } else if (data.type === 'read') { | |
| this.handleNotificationRead(data.notificationId); | |
| } | |
| } catch (err) { | |
| this.log('Error processing WebSocket message:', err); | |
| } | |
| }; | |
| this.webSocket.onclose = () => { | |
| this.isWebSocketConnected = false; | |
| this.log('WebSocket disconnected, reconnecting in 5 seconds...'); | |
| // Reconnect after 5 seconds | |
| setTimeout(() => this.connectWebSocket(), 5000); | |
| }; | |
| this.webSocket.onerror = (error) => { | |
| this.log('WebSocket error:', error); | |
| this.isWebSocketConnected = false; | |
| }; | |
| } catch (err) { | |
| this.log('Error connecting to WebSocket:', err); | |
| } | |
| } | |
| /** | |
| * Start polling for notifications | |
| */ | |
| startPolling() { | |
| if (this.isPolling) return; | |
| this.isPolling = true; | |
| this.pollForNotifications(); | |
| } | |
| /** | |
| * Poll for notifications | |
| */ | |
| pollForNotifications() { | |
| if (!this.isPolling) return; | |
| // Only poll if the page is visible and we're not using WebSockets | |
| if (document.visibilityState === 'visible' && !this.isWebSocketConnected) { | |
| this.fetchNotifications(); | |
| } | |
| // Schedule next poll | |
| setTimeout(() => this.pollForNotifications(), this.options.pollInterval); | |
| } | |
| /** | |
| * Fetch notifications from the server | |
| */ | |
| async fetchNotifications() { | |
| try { | |
| this.lastPollTime = Date.now(); | |
| const response = await fetch(`${this.options.serverUrl}?since=${this.lastPollTime - this.options.pollInterval}`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! Status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| // Process new notifications | |
| if (data.notifications && Array.isArray(data.notifications)) { | |
| data.notifications.forEach(notification => { | |
| this.handleNewNotification(notification); | |
| }); | |
| } | |
| // Update unread count | |
| if (data.unreadCount !== undefined) { | |
| this.unreadCount = data.unreadCount; | |
| this.updateUnreadBadge(); | |
| } | |
| } catch (err) { | |
| this.log('Error fetching notifications:', err); | |
| } | |
| } | |
| /** | |
| * Handle a new notification | |
| * @param {Object} notification - Notification data | |
| */ | |
| handleNewNotification(notification) { | |
| // Add to notifications list | |
| this.notifications.unshift(notification); | |
| // Limit the number of notifications we keep in memory | |
| if (this.notifications.length > 100) { | |
| this.notifications = this.notifications.slice(0, 100); | |
| } | |
| // Update unread count | |
| if (notification.state !== 'read') { | |
| this.unreadCount++; | |
| this.updateUnreadBadge(); | |
| } | |
| // Determine if we should show the notification based on: | |
| // 1. Priority | |
| // 2. Current cognitive load | |
| // 3. Current attention score | |
| // 4. User preferences | |
| const shouldShow = this.shouldShowNotification(notification); | |
| if (shouldShow) { | |
| // Display the notification based on channel | |
| this.displayNotification(notification); | |
| } else { | |
| // Queue for later if not showing now | |
| this.queueNotificationForLater(notification); | |
| } | |
| // Call notification received callback | |
| if (this.options.onNotificationReceived) { | |
| this.options.onNotificationReceived(notification); | |
| } | |
| } | |
| /** | |
| * Determine if a notification should be shown immediately | |
| * @param {Object} notification - Notification data | |
| * @returns {boolean} Whether to show the notification | |
| */ | |
| shouldShowNotification(notification) { | |
| // Always show critical notifications | |
| if (notification.priority === 'CRITICAL') { | |
| return true; | |
| } | |
| // Check user preferences | |
| const userPrefs = this.options.userPreferences || {}; | |
| const categoryPref = userPrefs[notification.category] || 'normal'; | |
| if (categoryPref === 'none') { | |
| return false; | |
| } | |
| // Check cognitive load threshold based on priority | |
| const loadThresholds = { | |
| 'HIGH': 0.8, // Show high priority unless cognitive load is very high | |
| 'MEDIUM': 0.6, // Show medium priority unless cognitive load is high | |
| 'LOW': 0.4, // Show low priority only when cognitive load is low | |
| 'BACKGROUND': 0.3 // Show background notifications only when cognitive load is very low | |
| }; | |
| const loadThreshold = loadThresholds[notification.priority] || 0.6; | |
| if (this.cognitiveLoadEstimate > loadThreshold) { | |
| return false; | |
| } | |
| // Check attention threshold based on priority | |
| const attentionThresholds = { | |
| 'HIGH': 0.3, // Show high priority even with low attention | |
| 'MEDIUM': 0.5, // Show medium priority with moderate attention | |
| 'LOW': 0.7, // Show low priority only with high attention | |
| 'BACKGROUND': 0.8 // Show background notifications only with very high attention | |
| }; | |
| const attentionThreshold = attentionThresholds[notification.priority] || 0.5; | |
| if (this.attentionScore < attentionThreshold) { | |
| return false; | |
| } | |
| // Check active notification limit | |
| if (this.activeNotifications.size >= this.options.maxNotifications) { | |
| // Only show if higher priority than existing notifications | |
| const lowestPriorityActive = this.getLowestPriorityActiveNotification(); | |
| const priorityValues = { | |
| 'CRITICAL': 0, | |
| 'HIGH': 1, | |
| 'MEDIUM': 2, | |
| 'LOW': 3, | |
| 'BACKGROUND': 4 | |
| }; | |
| if (priorityValues[notification.priority] >= priorityValues[lowestPriorityActive]) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * Get the lowest priority active notification | |
| * @returns {string} Notification priority | |
| */ | |
| getLowestPriorityActiveNotification() { | |
| let lowestPriority = 'CRITICAL'; | |
| const priorityValues = { | |
| 'CRITICAL': 0, | |
| 'HIGH': 1, | |
| 'MEDIUM': 2, | |
| 'LOW': 3, | |
| 'BACKGROUND': 4 | |
| }; | |
| for (const id of this.activeNotifications) { | |
| const notification = this.notifications.find(n => n.id === id); | |
| if (notification && priorityValues[notification.priority] > priorityValues[lowestPriority]) { | |
| lowestPriority = notification.priority; | |
| } | |
| } | |
| return lowestPriority; | |
| } | |
| /** | |
| * Queue a notification for later display | |
| * @param {Object} notification - Notification data | |
| */ | |
| queueNotificationForLater(notification) { | |
| // For now, we just log that we're queueing it | |
| this.log(`Queueing notification for later: ${notification.id}`); | |
| // In a full implementation, we would store this and periodically check if conditions are right to show it | |
| } | |
| /** | |
| * Display a notification | |
| * @param {Object} notification - Notification data | |
| */ | |
| displayNotification(notification) { | |
| // Add to active notifications | |
| this.activeNotifications.add(notification.id); | |
| // Display based on channel | |
| if (notification.channels.includes('MODAL')) { | |
| this.showModalNotification(notification); | |
| } else if (notification.channels.includes('TOAST')) { | |
| this.showToastNotification(notification); | |
| } else if (notification.channels.includes('UI')) { | |
| this.showUINotification(notification); | |
| } | |
| // Update cognitive load estimate | |
| this.updateCognitiveLoadEstimate(); | |
| } | |
| /** | |
| * Show a modal notification | |
| * @param {Object} notification - Notification data | |
| */ | |
| showModalNotification(notification) { | |
| const modalContainer = document.querySelector(this.options.modalContainer); | |
| if (!modalContainer) return; | |
| // Create modal element | |
| const modal = document.createElement('div'); | |
| modal.className = `mg-modal mg-priority-${notification.priority.toLowerCase()}`; | |
| modal.dataset.notificationId = notification.id; | |
| // Add modal content | |
| modal.innerHTML = ` | |
| <div class="mg-modal-content"> | |
| <div class="mg-modal-header"> | |
| <h3>${notification.title}</h3> | |
| <button class="mg-modal-close">×</button> | |
| </div> | |
| <div class="mg-modal-body"> | |
| <p>${notification.content}</p> | |
| </div> | |
| <div class="mg-modal-footer"> | |
| ${this.renderNotificationActions(notification)} | |
| </div> | |
| </div> | |
| `; | |
| // Add event listeners | |
| modal.querySelector('.mg-modal-close').addEventListener('click', () => { | |
| this.dismissNotification(notification.id); | |
| modal.remove(); | |
| }); | |
| // Add action event listeners | |
| const actionButtons = modal.querySelectorAll('.mg-action-button'); | |
| actionButtons.forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| const actionId = e.target.dataset.actionId; | |
| this.handleNotificationAction(notification.id, actionId); | |
| // Close modal if the action should dismiss | |
| if (e.target.dataset.dismiss === 'true') { | |
| this.dismissNotification(notification.id); | |
| modal.remove(); | |
| } | |
| }); | |
| }); | |
| // Add to DOM | |
| modalContainer.appendChild(modal); | |
| // Update session context | |
| this.sessionContext.activeModals++; | |
| } | |
| /** | |
| * Show a toast notification | |
| * @param {Object} notification - Notification data | |
| */ | |
| showToastNotification(notification) { | |
| const toastContainer = document.querySelector(this.options.toastContainer); | |
| if (!toastContainer) return; | |
| // Create toast element | |
| const toast = document.createElement('div'); | |
| toast.className = `mg-toast mg-priority-${notification.priority.toLowerCase()}`; | |
| toast.dataset.notificationId = notification.id; | |
| // Add icon if available | |
| const iconHtml = notification.icon ? | |
| `<div class="mg-toast-icon"><i class="fa fa-${notification.icon}"></i></div>` : ''; | |
| // Add toast content | |
| toast.innerHTML = ` | |
| ${iconHtml} | |
| <div class="mg-toast-content"> | |
| <div class="mg-toast-header"> | |
| <h4>${notification.title}</h4> | |
| <button class="mg-toast-close">×</button> | |
| </div> | |
| <div class="mg-toast-body"> | |
| <p>${notification.content}</p> | |
| </div> | |
| <div class="mg-toast-actions"> | |
| ${this.renderNotificationActions(notification)} | |
| </div> | |
| </div> | |
| `; | |
| // Add event listeners | |
| toast.querySelector('.mg-toast-close').addEventListener('click', () => { | |
| this.dismissNotification(notification.id); | |
| this.removeToast(toast); | |
| }); | |
| // Add action event listeners | |
| const actionButtons = toast.querySelectorAll('.mg-action-button'); | |
| actionButtons.forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| const actionId = e.target.dataset.actionId; | |
| this.handleNotificationAction(notification.id, actionId); | |
| // Close toast if the action should dismiss | |
| if (e.target.dataset.dismiss === 'true') { | |
| this.dismissNotification(notification.id); | |
| this.removeToast(toast); | |
| } | |
| }); | |
| }); | |
| // Add to DOM | |
| toastContainer.appendChild(toast); | |
| // Auto-hide after delay (for non-critical notifications) | |
| if (notification.priority !== 'CRITICAL' && !notification.requires_acknowledgment) { | |
| setTimeout(() => { | |
| this.removeToast(toast); | |
| }, this.options.autoHideDelay); | |
| } | |
| // Animate in | |
| setTimeout(() => { | |
| toast.classList.add('mg-toast-visible'); | |
| }, 10); | |
| } | |
| /** | |
| * Remove a toast notification with animation | |
| * @param {HTMLElement} toast - Toast element | |
| */ | |
| removeToast(toast) { | |
| toast.classList.remove('mg-toast-visible'); | |
| toast.classList.add('mg-toast-hiding'); | |
| // Remove from DOM after animation | |
| setTimeout(() => { | |
| if (toast.parentNode) { | |
| toast.parentNode.removeChild(toast); | |
| } | |
| // Remove from active notifications | |
| const notificationId = toast.dataset.notificationId; | |
| if (notificationId) { | |
| this.activeNotifications.delete(notificationId); | |
| } | |
| // Update cognitive load estimate | |
| this.updateCognitiveLoadEstimate(); | |
| }, 300); | |
| } | |
| /** | |
| * Show a UI notification | |
| * @param {Object} notification - Notification data | |
| */ | |
| showUINotification(notification) { | |
| const notificationContainer = document.querySelector(this.options.notificationContainer); | |
| if (!notificationContainer) return; | |
| // Create notification element | |
| const notificationEl = document.createElement('div'); | |
| notificationEl.className = `mg-notification mg-priority-${notification.priority.toLowerCase()}`; | |
| notificationEl.dataset.notificationId = notification.id; | |
| // Add icon if available | |
| const iconHtml = notification.icon ? | |
| `<div class="mg-notification-icon"><i class="fa fa-${notification.icon}"></i></div>` : ''; | |
| // Add notification content | |
| notificationEl.innerHTML = ` | |
| ${iconHtml} | |
| <div class="mg-notification-content"> | |
| <div class="mg-notification-header"> | |
| <h4>${notification.title}</h4> | |
| <span class="mg-notification-time">${this.formatTime(notification.created_at)}</span> | |
| </div> | |
| <div class="mg-notification-body"> | |
| <p>${notification.content}</p> | |
| </div> | |
| <div class="mg-notification-actions"> | |
| ${this.renderNotificationActions(notification)} | |
| </div> | |
| </div> | |
| `; | |
| // Add click event to mark as read | |
| notificationEl.addEventListener('click', (e) => { | |
| // Don't mark as read if clicking an action button | |
| if (e.target.closest('.mg-action-button')) { | |
| return; | |
| } | |
| this.markAsRead(notification.id); | |
| }); | |
| // Add action event listeners | |
| const actionButtons = notificationEl.querySelectorAll('.mg-action-button'); | |
| actionButtons.forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| const actionId = e.target.dataset.actionId; | |
| this.handleNotificationAction(notification.id, actionId); | |
| }); | |
| }); | |
| // Add to DOM at the top of the container | |
| if (notificationContainer.firstChild) { | |
| notificationContainer.insertBefore(notificationEl, notificationContainer.firstChild); | |
| } else { | |
| notificationContainer.appendChild(notificationEl); | |
| } | |
| } | |
| /** | |
| * Render notification action buttons | |
| * @param {Object} notification - Notification data | |
| * @returns {string} HTML for action buttons | |
| */ | |
| renderNotificationActions(notification) { | |
| if (!notification.actions || !notification.actions.length) { | |
| return ''; | |
| } | |
| return notification.actions.map(action => { | |
| const btnClass = action.style || 'primary'; | |
| const dismiss = action.dismiss ? 'true' : 'false'; | |
| return `<button class="mg-action-button mg-btn-${btnClass}" data-action-id="${action.id}" data-dismiss="${dismiss}">${action.label}</button>`; | |
| }).join(''); | |
| } | |
| /** | |
| * Format a timestamp into a human-readable string | |
| * @param {string} timestamp - ISO timestamp | |
| * @returns {string} Formatted time | |
| */ | |
| formatTime(timestamp) { | |
| const date = new Date(timestamp); | |
| const now = new Date(); | |
| const diff = now - date; | |
| // Show relative time for recent notifications | |
| if (diff < 60000) { | |
| return 'Just now'; | |
| } else if (diff < 3600000) { | |
| const minutes = Math.floor(diff / 60000); | |
| return `${minutes}m ago`; | |
| } else if (diff < 86400000) { | |
| const hours = Math.floor(diff / 3600000); | |
| return `${hours}h ago`; | |
| } else { | |
| // For older notifications, show the date | |
| return date.toLocaleDateString(); | |
| } | |
| } | |
| /** | |
| * Handle a notification action | |
| * @param {string} notificationId - Notification ID | |
| * @param {string} actionId - Action ID | |
| */ | |
| async handleNotificationAction(notificationId, actionId) { | |
| try { | |
| // Find the notification | |
| const notification = this.notifications.find(n => n.id === notificationId); | |
| if (!notification) return; | |
| // Find the action | |
| const action = notification.actions.find(a => a.id === actionId); | |
| if (!action) return; | |
| // Log the action | |
| this.log(`Notification action ${actionId} for notification ${notificationId}`); | |
| // Send to server | |
| await fetch(`${this.options.serverUrl}/${notificationId}/action`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| actionId, | |
| timestamp: new Date().toISOString() | |
| }) | |
| }); | |
| // If the action should dismiss the notification | |
| if (action.dismiss) { | |
| this.dismissNotification(notificationId); | |
| } | |
| // Execute client-side action handler if available | |
| if (action.clientHandler && typeof window[action.clientHandler] === 'function') { | |
| window[action.clientHandler](notification, action); | |
| } | |
| } catch (err) { | |
| this.log('Error handling notification action:', err); | |
| } | |
| } | |
| /** | |
| * Mark a notification as read | |
| * @param {string} notificationId - Notification ID | |
| */ | |
| async markAsRead(notificationId) { | |
| try { | |
| // Find the notification | |
| const notification = this.notifications.find(n => n.id === notificationId); | |
| if (!notification) return; | |
| // Skip if already read | |
| if (notification.state === 'read') return; | |
| // Update state locally | |
| notification.state = 'read'; | |
| notification.read_at = new Date().toISOString(); | |
| // Update unread count | |
| this.unreadCount = Math.max(0, this.unreadCount - 1); | |
| this.updateUnreadBadge(); | |
| // Send to server | |
| await fetch(`${this.options.serverUrl}/${notificationId}/read`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| timestamp: notification.read_at | |
| }) | |
| }); | |
| // Update UI | |
| const notificationEl = document.querySelector(`[data-notification-id="${notificationId}"]`); | |
| if (notificationEl) { | |
| notificationEl.classList.add('mg-notification-read'); | |
| } | |
| // Call notification read callback | |
| if (this.options.onNotificationRead) { | |
| this.options.onNotificationRead(notification); | |
| } | |
| } catch (err) { | |
| this.log('Error marking notification as read:', err); | |
| } | |
| } | |
| /** | |
| * Dismiss a notification | |
| * @param {string} notificationId - Notification ID | |
| */ | |
| dismissNotification(notificationId) { | |
| // Remove from active notifications | |
| this.activeNotifications.delete(notificationId); | |
| // Mark as read | |
| this.markAsRead(notificationId); | |
| // Update cognitive load estimate | |
| this.updateCognitiveLoadEstimate(); | |
| } | |
| /** | |
| * Acknowledge a notification (for notifications that require acknowledgment) | |
| * @param {string} notificationId - Notification ID | |
| * @param {Object} data - Acknowledgment data | |
| */ | |
| async acknowledgeNotification(notificationId, data = {}) { | |
| try { | |
| // Find the notification | |
| const notification = this.notifications.find(n => n.id === notificationId); | |
| if (!notification) return; | |
| // Skip if already acknowledged or doesn't require acknowledgment | |
| if (notification.state === 'read' || !notification.requires_acknowledgment) return; | |
| // Update state locally | |
| notification.state = 'read'; | |
| notification.read_at = new Date().toISOString(); | |
| notification.acknowledgment_data = { | |
| ...data, | |
| timestamp: notification.read_at | |
| }; | |
| // Update unread count | |
| this.unreadCount = Math.max(0, this.unreadCount - 1); | |
| this.updateUnreadBadge(); | |
| // Send to server | |
| await fetch(`${this.options.serverUrl}/${notificationId}/acknowledge`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(notification.acknowledgment_data) | |
| }); | |
| // Update UI | |
| const notificationEl = document.querySelector(`[data-notification-id="${notificationId}"]`); | |
| if (notificationEl) { | |
| notificationEl.classList.add('mg-notification-read'); | |
| } | |
| // Call notification read callback | |
| if (this.options.onNotificationRead) { | |
| this.options.onNotificationRead(notification); | |
| } | |
| } catch (err) { | |
| this.log('Error acknowledging notification:', err); | |
| } | |
| } | |
| /** | |
| * Handle notification read event | |
| * @param {string} notificationId - Notification ID | |
| */ | |
| handleNotificationRead(notificationId) { | |
| // Find the notification | |
| const notification = this.notifications.find(n => n.id === notificationId); | |
| if (!notification) return; | |
| // Skip if already read | |
| if (notification.state === 'read') return; | |
| // Update state locally | |
| notification.state = 'read'; | |
| notification.read_at = new Date().toISOString(); | |
| // Update unread count | |
| this.unreadCount = Math.max(0, this.unreadCount - 1); | |
| this.updateUnreadBadge(); | |
| // Update UI | |
| const notificationEl = document.querySelector(`[data-notification-id="${notificationId}"]`); | |
| if (notificationEl) { | |
| notificationEl.classList.add('mg-notification-read'); | |
| } | |
| // Call notification read callback | |
| if (this.options.onNotificationRead) { | |
| this.options.onNotificationRead(notification); | |
| } | |
| } | |
| /** | |
| * Update the unread notification badge | |
| */ | |
| updateUnreadBadge() { | |
| // Find all elements with the notification badge class | |
| const badges = document.querySelectorAll('.mg-notification-badge'); | |
| badges.forEach(badge => { | |
| if (this.unreadCount > 0) { | |
| badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount; | |
| badge.classList.add('mg-notification-badge-visible'); | |
| } else { | |
| badge.classList.remove('mg-notification-badge-visible'); | |
| } | |
| }); | |
| } | |
| /** | |
| * Get all notifications | |
| * @returns {Array} Notifications | |
| */ | |
| getNotifications() { | |
| return [...this.notifications]; | |
| } | |
| /** | |
| * Get unread notifications | |
| * @returns {Array} Unread notifications | |
| */ | |
| getUnreadNotifications() { | |
| return this.notifications.filter(n => n.state !== 'read'); | |
| } | |
| /** | |
| * Get notifications by category | |
| * @param {string} category - Notification category | |
| * @returns {Array} Notifications in the category | |
| */ | |
| getNotificationsByCategory(category) { | |
| return this.notifications.filter(n => n.category === category); | |
| } | |
| /** | |
| * Clear all notifications | |
| */ | |
| async clearAllNotifications() { | |
| try { | |
| // Clear on server | |
| await fetch(`${this.options.serverUrl}/clear`, { | |
| method: 'POST' | |
| }); | |
| // Clear locally | |
| this.notifications = []; | |
| this.unreadCount = 0; | |
| this.activeNotifications.clear(); | |
| // Update UI | |
| this.updateUnreadBadge(); | |
| // Clear notification containers | |
| document.querySelector(this.options.notificationContainer).innerHTML = ''; | |
| document.querySelector(this.options.toastContainer).innerHTML = ''; | |
| // Update cognitive load estimate | |
| this.updateCognitiveLoadEstimate(); | |
| } catch (err) { | |
| this.log('Error clearing notifications:', err); | |
| } | |
| } | |
| /** | |
| * Get session context for the server | |
| * @returns {Object} Session context | |
| */ | |
| getContextForServer() { | |
| return { | |
| session_data: { | |
| is_active: this.sessionContext.isActive, | |
| last_activity: this.sessionContext.lastActivity, | |
| time_on_page: this.sessionContext.timeOnPage, | |
| page_transitions: this.sessionContext.pageTransitions, | |
| form_interactions: this.sessionContext.formInteractions | |
| }, | |
| cognitive_load: this.cognitiveLoadEstimate, | |
| attention_score: this.attentionScore, | |
| device_info: { | |
| viewport_width: window.innerWidth, | |
| viewport_height: window.innerHeight, | |
| user_agent: navigator.userAgent, | |
| is_mobile: /Mobi|Android/i.test(navigator.userAgent) | |
| } | |
| }; | |
| } | |
| /** | |
| * Set user preferences for notifications | |
| * @param {Object} preferences - User preferences | |
| */ | |
| setUserPreferences(preferences) { | |
| this.options.userPreferences = preferences; | |
| } | |
| /** | |
| * Log debug messages | |
| * @param {...any} args - Log arguments | |
| */ | |
| log(...args) { | |
| if (this.options.debug) { | |
| console.log('[NotificationClient]', ...args); | |
| } | |
| } | |
| } | |
| // CSS Styles for notifications | |
| const addNotificationStyles = () => { | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| /* Notification containers */ | |
| .mg-notification-container { | |
| max-height: 600px; | |
| overflow-y: auto; | |
| width: 320px; | |
| } | |
| .mg-toast-container { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| z-index: 9999; | |
| max-width: 350px; | |
| } | |
| .mg-modal-container { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| z-index: 10000; | |
| background-color: rgba(0, 0, 0, 0.5); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| /* Notification badge */ | |
| .mg-notification-badge { | |
| display: none; | |
| min-width: 20px; | |
| height: 20px; | |
| border-radius: 10px; | |
| background-color: #e74c3c; | |
| color: white; | |
| font-size: 12px; | |
| text-align: center; | |
| line-height: 20px; | |
| padding: 0 4px; | |
| } | |
| .mg-notification-badge-visible { | |
| display: inline-block; | |
| } | |
| /* UI Notification */ | |
| .mg-notification { | |
| display: flex; | |
| padding: 12px; | |
| margin-bottom: 8px; | |
| border-radius: 4px; | |
| background-color: #fff; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); | |
| border-left: 4px solid #3498db; | |
| transition: all 0.2s ease; | |
| } | |
| .mg-notification:hover { | |
| box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16); | |
| } | |
| .mg-notification-read { | |
| opacity: 0.7; | |
| border-left-color: #bdc3c7; | |
| } | |
| .mg-notification-content { | |
| flex: 1; | |
| } | |
| .mg-notification-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 4px; | |
| } | |
| .mg-notification-header h4 { | |
| margin: 0; | |
| font-size: 14px; | |
| font-weight: bold; | |
| } | |
| .mg-notification-time { | |
| font-size: 12px; | |
| color: #7f8c8d; | |
| } | |
| .mg-notification-body p { | |
| margin: 0; | |
| font-size: 13px; | |
| color: #2c3e50; | |
| } | |
| .mg-notification-actions { | |
| margin-top: 8px; | |
| display: flex; | |
| gap: 8px; | |
| } | |
| /* Toast notification */ | |
| .mg-toast { | |
| display: flex; | |
| padding: 12px; | |
| margin-bottom: 10px; | |
| border-radius: 4px; | |
| background-color: #fff; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
| max-width: 100%; | |
| opacity: 0; | |
| transform: translateY(-20px); | |
| transition: all 0.3s ease; | |
| } | |
| .mg-toast-visible { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| .mg-toast-hiding { | |
| opacity: 0; | |
| transform: translateY(-20px); | |
| } | |
| .mg-toast-icon { | |
| margin-right: 12px; | |
| font-size: 20px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .mg-toast-content { | |
| flex: 1; | |
| } | |
| .mg-toast-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 4px; | |
| } | |
| .mg-toast-header h4 { | |
| margin: 0; | |
| font-size: 14px; | |
| font-weight: bold; | |
| } | |
| .mg-toast-close { | |
| background: none; | |
| border: none; | |
| font-size: 16px; | |
| cursor: pointer; | |
| padding: 0; | |
| color: #7f8c8d; | |
| } | |
| .mg-toast-body p { | |
| margin: 0; | |
| font-size: 13px; | |
| color: #2c3e50; | |
| } | |
| .mg-toast-actions { | |
| margin-top: 8px; | |
| display: flex; | |
| gap: 8px; | |
| } | |
| /* Modal notification */ | |
| .mg-modal { | |
| width: 90%; | |
| max-width: 500px; | |
| background-color: #fff; | |
| border-radius: 4px; | |
| box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); | |
| overflow: hidden; | |
| } | |
| .mg-modal-content { | |
| padding: 0; | |
| } | |
| .mg-modal-header { | |
| padding: 16px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background-color: #f8f9fa; | |
| border-bottom: 1px solid #e9ecef; | |
| } | |
| .mg-modal-header h3 { | |
| margin: 0; | |
| font-size: 18px; | |
| font-weight: 600; | |
| } | |
| .mg-modal-close { | |
| background: none; | |
| border: none; | |
| font-size: 20px; | |
| cursor: pointer; | |
| padding: 0; | |
| color: #7f8c8d; | |
| } | |
| .mg-modal-body { | |
| padding: 16px; | |
| } | |
| .mg-modal-body p { | |
| margin: 0 0 12px 0; | |
| font-size: 14px; | |
| color: #2c3e50; | |
| line-height: 1.5; | |
| } | |
| .mg-modal-footer { | |
| padding: 12px 16px; | |
| border-top: 1px solid #e9ecef; | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 8px; | |
| } | |
| /* Priority colors */ | |
| .mg-priority-critical { | |
| border-left-color: #e74c3c; | |
| } | |
| .mg-priority-high { | |
| border-left-color: #e67e22; | |
| } | |
| .mg-priority-medium { | |
| border-left-color: #3498db; | |
| } | |
| .mg-priority-low { | |
| border-left-color: #2ecc71; | |
| } | |
| .mg-priority-background { | |
| border-left-color: #95a5a6; | |
| } | |
| /* Action buttons */ | |
| .mg-action-button { | |
| background: none; | |
| border: 1px solid #3498db; | |
| border-radius: 4px; | |
| padding: 6px 12px; | |
| font-size: 12px; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .mg-btn-primary { | |
| background-color: #3498db; | |
| color: white; | |
| } | |
| .mg-btn-primary:hover { | |
| background-color: #2980b9; | |
| } | |
| .mg-btn-secondary { | |
| background-color: transparent; | |
| color: #3498db; | |
| } | |
| .mg-btn-secondary:hover { | |
| background-color: #eef8ff; | |
| } | |
| .mg-btn-danger { | |
| background-color: #e74c3c; | |
| border-color: #e74c3c; | |
| color: white; | |
| } | |
| .mg-btn-danger:hover { | |
| background-color: #c0392b; | |
| } | |
| .mg-btn-success { | |
| background-color: #2ecc71; | |
| border-color: #2ecc71; | |
| color: white; | |
| } | |
| .mg-btn-success:hover { | |
| background-color: #27ae60; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| }; | |
| // Add styles when the DOM is loaded | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', addNotificationStyles); | |
| } else { | |
| addNotificationStyles(); | |
| } | |
| // Create a global reference to the notification client | |
| window.notificationClient = null; | |
| // Initialize the notification client | |
| window.initNotificationClient = (options = {}) => { | |
| window.notificationClient = new NotificationClient(options); | |
| return window.notificationClient; | |
| }; | |
| // Export for module usage | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| NotificationClient, | |
| initNotificationClient: (options) => { | |
| window.notificationClient = new NotificationClient(options); | |
| return window.notificationClient; | |
| } | |
| }; | |
| } |