/** * 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 = `
`; // 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 ? `` : ''; // Add toast content toast.innerHTML = ` ${iconHtml}${notification.content}
${notification.content}