1// notifications.js - Proactive notifications + chat mirroring 2// v1.8 - 24h history + dedupe + quiet errors + drip queue + chat_mirror filters 3class ProactiveNotifications { 4 constructor() { 5 this.pollingInterval = 30000; // 30 seconds 6 this.lastCheck = null; 7 this.isPolling = false; 8 9 // Config: also mirror each alert into chat window if present 10 this.mirrorToChat = true; 11 12 13 // Disable right-side toast/popup notifications; chat is the single UI surface 14 this.enableToasts = false; 15// Track whether we've already loaded history for this page view 16 this.historyLoaded = false; 17 18 // Track which alerts we've already shown (avoid duplicates) 19 this.seenAlertIds = new Set(); 20 21 // Debounced fetch hook (can be used later if needed) 22 this.debouncedFetch = this.debounce(this.fetchNotifications.bind(this), 1000); 23 24 // Optional offline bar reference (used by handleFetchError if you want) 25 this.offlineBar = null; 26 27 // === CHAT DRIP QUEUE === 28 // Queue of historical alerts to be dripped into chat 29 this.chatQueue = []; 30 this.chatQueueTimer = null; 31 32 // Drip timing (ms) â tweaked for slower, softer drip 33 this.initialDripDelay = 15000; // first history/live message after 15s 34 this.dripInterval = 90000; // then one every 90s 35 36 // === FYF CHAT MIRROR CONTROLS (server-friendly) === 37 // Turn OFF chat-history mirroring by default (prevents "dumping" many sent messages into chat on page load) 38 this.chatHistoryEnabled = (window.FYF_CHAT_HISTORY_ENABLED === true); 39 40 // Max alerts mirrored into chat per fetch cycle (live), to avoid chat flooding even if bridge returns many 41 this.chatMirrorMaxPerFetch = (typeof window.FYF_CHAT_MIRROR_MAX_PER_FETCH === 'number') 42 ? window.FYF_CHAT_MIRROR_MAX_PER_FETCH : 2; 43 this.chatMirrorCountThisFetch = 0; 44 } 45 46 // Small helper: detect internal test / smoke notifications 47 isInternalTestNotification(notification) { 48 const title = (notification.title || '').toLowerCase(); 49 const msg = (notification.message || '').toLowerCase(); 50 51 if (!title && !msg) return false; 52 53 // 1) FYF ping and generic "Notification" 54 if (title.includes('fyf in-app ping')) return true; 55 if (title === 'notification' && !msg) return true; 56 57 // 2) SMOKE / chat smoke formats 58 if (title.includes('[smoke]')) return true; 59 if (title.includes('bridge chat smoke')) return true; 60 if (title.includes('page-first chat smoke')) return true; 61 if (title.includes('chat smoke')) return true; 62 if (title.includes('live in-app test')) return true; 63 if (title.includes('sanity ping')) return true; 64 65 // 3) Typical test message bodies 66 if (msg.includes('this is a test in-app notification')) return true; 67 if (msg.includes('chat mirror test')) return true; 68 if (msg.includes('toast + chat mirror')) return true; 69 if (msg.includes('engine in-app-level test')) return true; 70 if (msg.includes('testing immediate proactive trigger')) return true; 71 if (msg.includes('this should appear in the chatbot window')) return true; 72 73 return false; 74 } 75 76 // Track + // dedupe: return true if this ID is new, false if seen before (PERSISTED across reloads) 77 // Stores a small rolling cache in localStorage per user to prevent chat-history replays. 78 _seenStorageKey() { 79 const uid = this.getUserId(); 80 const safe = (uid !== undefined && uid !== null && String(uid).length > 0) ? String(uid) : 'anon'; 81 return `fyf_seen_alert_ids_${safe}`; 82 } 83 84 _loadSeenFromStorage() { 85 try { 86 const raw = localStorage.getItem(this._seenStorageKey()); 87 if (!raw) return; 88 const obj = JSON.parse(raw); 89 if (!obj || typeof obj !== 'object') return; 90 91 const now = Date.now(); 92 const ttlMs = 48 * 60 * 60 * 1000; // keep 48h to cover daily history loads 93 const maxKeys = 500; 94 95 const entries = Object.entries(obj) 96 .filter(([k, ts]) => typeof ts === 'number' && (now - ts) <= ttlMs) 97 .sort((a, b) => b[1] - a[1]) // newest first 98 .slice(0, maxKeys); 99 100 this.seenAlertIds = new Set(entries.map(([k]) => String(k))); 101 this._seenPersistObj = Object.fromEntries(entries); 102 } catch (e) { 103 // ignore storage errors 104 } 105 } 106 107 _saveSeenToStorageThrottled() { 108 try { 109 if (this._seenSaveTimer) return; 110 this._seenSaveTimer = setTimeout(() => { 111 this._seenSaveTimer = null; 112 try { 113 if (!this._seenPersistObj) this._seenPersistObj = {}; 114 localStorage.setItem(this._seenStorageKey(), JSON.stringify(this._seenPersistObj)); 115 } catch (e) {} 116 }, 250); 117 } catch (e) {} 118 } 119 120 markSeen(id) { 121 if (id === undefined || id === null) { 122 // No ID â treat as always displayable 123 return true; 124 }
125 const key = String(id); 126 127 // Lazy-load persisted seen set once per page 128 if (!this._seenLoaded) { 129 this._seenLoaded = true; 130 this._loadSeenFromStorage(); 131 } 132 133 if (this.seenAlertIds && this.seenAlertIds.has(key)) { 134 return false; 135 } 136 if (!this.seenAlertIds) this.seenAlertIds = new Set(); 137 this.seenAlertIds.add(key); 138 139 // Persist with TTL 140 if (!this._seenPersistObj) this._seenPersistObj = {}; 141 this._seenPersistObj[key] = Date.now(); 142 this._saveSeenToStorageThrottled(); 143 144 return true; 145 } 146 147 // ---- NEW: metadata + chat-mirror filter helpers ---- 148 149 // Safely decode metadata (string or object) from bridge 150 getNotificationMeta(notification) { 151 const raw = notification && notification.metadata; 152 if (!raw) return {}; 153 if (typeof raw === 'object') { 154 return raw || {}; 155 } 156 if (typeof raw === 'string') { 157 try { 158 return JSON.parse(raw); 159 } catch (e) { 160 return {}; 161 } 162 } 163 return {}; 164 } 165 166 /** 167 * Decide if this notification should be mirrored into chat. 168 * Uses: 169 * - metadata.chat_mirror === false â NEVER mirror 170 * - metadata.family / metadata.event / metadata.template 171 * - basic word filters for long explainers if needed 172 */ 173 shouldMirrorToChat(notification) { 174 const meta = this.getNotificationMeta(notification); 175 176 // 1) Explicit override from engine/templates 177 if (Object.prototype.hasOwnProperty.call(meta, 'chat_mirror') && 178 meta.chat_mirror === false) { 179 return false; 180 } 181 182 // 2) Family / event-based suppression 183 const family = (meta.family || meta.event_family || '').toString(); 184 const eventKey = (meta.event || meta.template || meta.event_key || '').toString(); 185 186 const blockFamilies = [ 187 'profile_welcome', // onboarding / greeting 188 'ai_spiritual_guide', // long intro 189 'solar_style', // repeated Solar Style variants 190 'solar_style_variants', 191 'long_explainer', 192 'help_center' 193 ]; 194 195 const blockEvents = [ 196 'profile_welcome_intro', 197 'ai_spiritual_guide_intro', 198 'solar_style_daily', 199 'solar_style_variant_a', 200 'solar_style_variant_b', 201 'numerology_personal_year_explainer', 202 'what_is_numerology_explainer' 203 ]; 204 205 if (family && blockFamilies.includes(family)) { 206 return false; 207 } 208 if (eventKey && blockEvents.includes(eventKey)) { 209 return false; 210 } 211 212 // 3) Optional length-based guard â don't spam huge essays into chat 213 const body = (notification.message || '').trim(); 214 if (body.length > 1800) { 215 // Long-form article-style content â toast OK, but skip chat 216 return false; 217 } 218 219 return true; 220 } 221 222 // Initialize notification system 223 init() { 224 console.log('Initializing proactive notifications...'); 225 226 // 1) Optionally load historical messages into chat (sent history) 227 const maybeHistory = this.chatHistoryEnabled ? this.fetchChatHistory() : Promise.resolve({success:true,alerts:[]}); 228 maybeHistory 229 .then(() => { 230 // 2) Then fetch new notifications (live queue) 231 return this.fetchNotifications(); 232 }) 233 .catch(err => { 234 console.warn('Init error (history + initial fetch):', err); 235 }); 236 237 // 3) Start periodic polling (may be replaced by resilient wrapper later) 238 this.startPolling(); 239 240 // 4) When tab becomes visible again, refresh both history + new 241 document.addEventListener('visibilitychange', () => { 242 if (!document.hidden) { 243 this.fetchChatHistory(); 244 this.fetchNotifications(); 245 } 246 }); 247 } 248 249 // Fetch notifications from server (live queue) 250 async fetchNotifications() { 251 this.chatMirrorCountThisFetch = 0; 252 try { 253 const params = new URLSearchParams(); 254 const uid = this.getUserId(); 255 if (typeof uid === 'number' || (/^\d+$/.test(String(uid)))) { 256 params.append('user_id', parseInt(uid, 10)); 257 } else if (uid) { 258 params.append('username', String(uid)); 259 } 260 params.append('mark', '1'); 261 if (this.lastCheck) params.append('since', this.lastCheck); 262 263 const response = await fetch(`/gpt/proactive_in_app_bridge.php?${params.toString()}`, { cache: 'no-store' }); 264 const data = await response.json(); 265 266 if (data.success && data.alerts && data.alerts.length > 0) { 267 console.log(`Received ${data.alerts.length} new notifications`);
268 data.alerts.forEach(notification => this.showNotification(notification)); 269 this.lastCheck = data.timestamp; 270 this.updateBadgeCount(data.alerts.length); 271 } 272 return data; 273 } catch (error) { 274 console.error('Failed to fetch notifications:', error); 275 this.handleFetchError(error); 276 return { success: false, error: error.message }; 277 } 278 } 279 280 // Fetch historical chat messages (already sent) and mirror into chat (DRIPPED) 281 async fetchChatHistory() { 282 try { 283 // Avoid spamming the bridge: only load once per page view by default 284 if (this.historyLoaded) { 285 return { success: true, alerts: [] }; 286 } 287 288 const params = new URLSearchParams(); 289 const uid = this.getUserId(); 290 if (typeof uid === 'number' || (/^\d+$/.test(String(uid)))) { 291 params.append('user_id', parseInt(uid, 10)); 292 } else if (uid) { 293 params.append('username', String(uid)); 294 } 295 296 // CRITICAL: this asks the bridge for *sent* messages (chat history) 297 params.append('mode', 'sent'); 298 params.append('limit', (window.FYF_CHAT_HISTORY_LIMIT ? String(window.FYF_CHAT_HISTORY_LIMIT) : '5')); 299 300 // Only pull last 24 hours of history to avoid duplicate-looking daily sets 301 const sinceDate = new Date(Date.now() - 24 * 60 * 60 * 1000); 302 const sinceISO = sinceDate.toISOString(); // UTC ISO is fine; bridge just needs a cutoff 303 params.append('since', sinceISO); 304 305 const response = await fetch(`/gpt/proactive_in_app_bridge.php?${params.toString()}`, { 306 cache: 'no-store' 307 }); 308 const data = await response.json(); 309 310 if (data.success && data.alerts && data.alerts.length > 0) { 311 console.log(`Loaded ${data.alerts.length} chat history messages`); 312 313 // Mirror historical messages into chat via DRIP QUEUE (no toasts) 314 let immediateCount = 0; 315 const maxImmediate = 1; // show first 1 immediately, drip the rest 316 317 data.alerts.forEach(notification => { 318 if (this.markSeen(notification.id)) { 319 const immediate = immediateCount < maxImmediate; 320 this.enqueueChatAlert(notification, { immediate, isHistory: true }); 321 if (immediate) immediateCount++; 322 } 323 }); 324 } 325 326 this.historyLoaded = true; 327 return data; 328 } catch (error) { 329 console.error('Failed to fetch chat history:', error); 330 this.handleFetchError(error); 331 return { success: false, error: error.message }; 332 } 333 } 334 335 // Display individual notification (LIVE) 336 showNotification(notification) { 337 // ð DE-DUPE: skip if we've already handled this id 338 if (!this.markSeen(notification.id)) { 339 console.log('Skipping duplicate notification id', notification.id); 340 return; 341 } 342 343 // ð HARD FILTER: skip internal test/smoke notifications entirely 344 if (this.isInternalTestNotification(notification)) { 345 console.log('Skipping internal/test notification:', notification.title || notification.message); 346 return; 347 } 348 349 // â Mirror to chat window (single source of truth UI) 350 if (this.mirrorToChat) { 351 if (this.chatMirrorCountThisFetch < this.chatMirrorMaxPerFetch) { 352 this.chatMirrorCountThisFetch++; 353 this.enqueueChatAlert(notification, { immediate: false, isHistory: false }); 354 } 355 } 356 357 // ð Right-side toast/popup UI disabled by default (prevents duplicates) 358 if (this.enableToasts) { 359 const notificationElement = this.createNotificationElement(notification); 360 const container = this.getNotificationContainer(); 361 container.appendChild(notificationElement); 362 363 setTimeout(() => { 364 notificationElement.classList.add('fade-out'); 365 setTimeout(() => notificationElement.parentNode && notificationElement.parentNode.removeChild(notificationElement), 500); 366 }, 8000); 367 } 368 369 // Optional sound for high priority 370 if (notification.priority === 'high') this.playNotificationSound(); 371 372 console.log('Handled notification:', notification.title); 373 } 374 375 // === enqueue alert into chat drip system === 376 enqueueChatAlert(notification, { immediate = false, isHistory = false } = {}) { 377 // Skip internal tests just in case 378 if (this.isInternalTestNotification(notification)) { 379 return; 380 } 381 382 // NEW: obey metadata / family-based chat suppression 383 if (!this.shouldMirrorToChat(notification)) { 384 return; 385 } 386 387 const text = this.formatAlertForChat(notification); 388 if (!text) return; 389 390 if (immediate) { 391 // Render straight away 392 this.renderChatMessage(text, notification); 393 return; 394 } 395 396 // Queue for dripping 397 this.chatQueue.push({ notification, text, isHistory }); 398 399 // If timer not running, start it 400 if (!this.chatQueueTimer) { 401 this.chatQueueTimer = setTimeout( 402 () => this.flushOneFromQueue(), 403 this.initialDripDelay 404 ); 405 } 406 } 407 408 // === flush exactly one queued message, then reschedule if needed === 409 flushOneFromQueue() { 410 const item = this.chatQueue.shift(); 411 if (!item) { 412 this.chatQueueTimer = null; 413 return; 414 } 415 416 this.renderChatMessage(item.text, item.notification); 417 418 if (this.chatQueue.length > 0) { 419 this.chatQueueTimer = setTimeout( 420 () => this.flushOneFromQueue(), 421 this.dripInterval 422 ); 423 } else { 424 this.chatQueueTimer = null; 425 } 426 } 427 428 // formatting helper for chat text 429 formatAlertForChat(notification) { 430 const title = notification.title || ''; 431 const msg = notification.message || ''; 432 if (!title && !msg) return ''; 433 return (title ? title + ' â ' : '') + msg; 434 } 435 436 // render helper that knows about homeChat + fallback DOM 437 renderChatMessage(text, notification) { 438 try { 439 // Preferred: homeChat API 440 if (window.homeChat && typeof window.homeChat.addMessageToChat === 'function') {
441 // Support either (text, 'assistant') or object style 442 try { 443 window.homeChat.addMessageToChat(text, 'assistant'); 444 } catch (e) { 445 // If new signature: pass object 446 window.homeChat.addMessageToChat({ 447 from: 'assistant', 448 text, 449 meta: { 450 alert_id: notification && notification.id, 451 alert_type: notification && (notification.alert_type || notification.type || null), 452 } 453 }); 454 } 455 return; 456 } 457 458 // Fallback: plain DOM injection under chat container 459 const chatMsgs = document.querySelector('.chat-messages, #aiChatMessages'); 460 if (chatMsgs) { 461 const div = document.createElement('div'); 462 div.className = 'chat-message assistant-message'; 463 div.style.cssText = 'background: rgba(142, 196, 255, 0.1); border: 1px solid #8ec4ff; border-radius: 18px; padding: 14px 18px; margin: 10px 0; max-width: 85%;'; 464 div.textContent = text; 465 chatMsgs.appendChild(div); 466 chatMsgs.scrollTop = chatMsgs.scrollHeight; 467 } 468 } catch (e) { 469 console.warn('Chat render failed:', e); 470 } 471 } 472 473 // Mirror under chat (HomeAIChat / AsteriaGPT) if present 474 // NOTE: kept for compatibility (fallback system uses it), but main flow now uses enqueueChatAlert + renderChatMessage 475 mirrorNotificationToChat(notification) { 476 // ð HARD FILTER: skip internal tests in chat as well 477 if (this.isInternalTestNotification(notification)) { 478 return; 479 } 480 481 // NEW: obey chat-mirror suppression here as well 482 if (!this.shouldMirrorToChat(notification)) { 483 return; 484 } 485 486 const text = this.formatAlertForChat(notification); 487 if (!text) return; 488 489 this.renderChatMessage(text, notification); 490 } 491 492 // Create notification HTML element 493 createNotificationElement(notification) { 494 const div = document.createElement('div'); 495 div.className = `notification notification-${notification.priority} notification-${notification.alert_type || 'custom'}`; 496 div.setAttribute('role', 'alert'); 497 div.setAttribute('aria-live', notification.priority === 'high' ? 'assertive' : 'polite'); 498 div.innerHTML = ` 499 <div class="notification-icon">${this.getNotificationIcon(notification.alert_type)}</div> 500 <div class="notification-content"> 501 <div class="notification-title">${this.escapeHtml(notification.title || '')}</div> 502 <div class="notification-message">${this.escapeHtml(notification.message || '')}</div> 503 <div class="notification-time">${this.formatTime(notification.sent_at || notification.created_at)}</div> 504 </div> 505 <button class="notification-close" onclick="this.parentElement.remove()" aria-label="Close notification">Ã</button> 506 `; 507 div.addEventListener('click', (event) => { 508 this.handleNotificationClick(notification, event); 509 }); 510 return div; 511 } 512 513 // Show toast notification 514 showToast(notification) { 515 if (!this.isToastEnabled()) return; 516 if ('Notification' in window && Notification.permission === 'granted') { 517 new Notification(notification.title || 'Update', { 518 body: notification.message || '', 519 icon: '/images/astrology-icon.png', 520 tag: `proactive-${notification.id}` 521 }); 522 } else { 523 this.showCustomToast(notification); 524 } 525 } 526 527 // Custom toast fallback 528 showCustomToast(notification) { 529 const toast = document.createElement('div'); 530 toast.className = 'custom-toast'; 531 toast.innerHTML = `<strong>${this.escapeHtml(notification.title || 'Update')}</strong><br>${this.escapeHtml(notification.message || '')}`; 532 document.body.appendChild(toast); 533 setTimeout(() => toast.classList.add('show'), 100); 534 setTimeout(() => { 535 toast.classList.remove('show'); 536 setTimeout(() => toast.parentNode && toast.parentNode.removeChild(toast), 500); 537 }, 5000); 538 } 539 540 // Start polling 541 startPolling() { 542 if (this.isPolling) return; 543 this.isPolling = true; 544 this.pollInterval = setInterval(() => this.fetchNotifications(), this.pollingInterval); 545 } 546 547 stopPolling() { 548 this.isPolling = false;
549 if (this.pollInterval) clearInterval(this.pollInterval); 550 } 551 552 // Click handler 553 handleNotificationClick(notification, event) { 554 console.log('Notification clicked:', notification); 555 // Safe defaultâno nav 556 event.currentTarget.classList.add('fade-out'); 557 setTimeout(() => event.currentTarget.parentNode && event.currentTarget.parentNode.removeChild(event.currentTarget), 500); 558 } 559 560 // Error handler (network / JSON etc.) â QUIET (no top banner) 561 handleFetchError(error) { 562 if (!error) return; 563 console.warn('Notification fetch error:', error); 564 } 565 566 showOfflineIndicator(text) { 567 // Legacy helper left in place (not used anymore) 568 console.log(text || 'Youâre offline. Weâll retry automaticallyâ¦'); 569 } 570 571 hideOfflineIndicator() { 572 if (this.offlineBar) { 573 this.offlineBar.style.display = 'none'; 574 } 575 } 576 577 // Utils 578 getUserId() { 579 const win = window; 580 const fromFyf = win.fyf_profile && (win.fyf_profile.user_id || win.fyf_profile.login_name || win.fyf_profile.username); 581 const id = (win.userProfile && win.userProfile.user_id) || win.currentUserId || fromFyf || (win.fyf_profile && win.fyf_profile.user_id); 582 // old default was 1; avoid fake user and return null so fetch can skip 583 return id || null; 584 } 585 586 getNotificationContainer() {
587 let container = document.getElementById('notifications-container'); 588 if (!container) { 589 container = document.createElement('div'); 590 container.id = 'notifications-container'; 591 container.className = 'notifications-container'; 592 document.body.appendChild(container); 593 } 594 return container; 595 } 596 597 getNotificationIcon(alertType) { 598 const icons = { birthday:'ð', transit:'ð', retrograde:'ð', lunar:'ð', personal_year:'ð ', custom:'â¨', welcome:'ð' }; 599 return icons[alertType] || 'ð'; 600 } 601 602 updateBadgeCount(count) { 603 const badge = document.getElementById('notification-badge'); 604 if (badge) { 605 badge.textContent = String(count); 606 badge.style.display = count > 0 ? 'block' : 'none'; 607 } 608 } 609 610 formatTime(timestamp) { 611 if (!timestamp) return 'Just now'; 612 const time = new Date(timestamp); 613 const now = new Date(); 614 const diffMs = now - time; 615 const diffMins = Math.floor(diffMs / 60000); 616 if (diffMins < 1) return 'Just now'; 617 if (diffMins < 60) return `${diffMins}m ago`; 618 if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; 619 return time.toLocaleDateString(); 620 } 621 622 escapeHtml(text) { 623 const div = document.createElement('div'); 624 div.textContent = text; 625 return div.innerHTML; 626 } 627 628 playNotificationSound() { 629 const audio = new Audio('/sounds/notification.mp3'); 630 audio.volume = 0.3; 631 audio.play().catch(()=>{}); 632 } 633 634 isToastEnabled() { 635 return localStorage.getItem('toastNotifications') !== 'false'; 636 } 637 638 // Debounce helper 639 debounce(func, wait) { 640 let timeout; 641 return function executedFunction(...args) { 642 const later = () => { 643 clearTimeout(timeout); 644 func(...args); 645 }; 646 clearTimeout(timeout); 647 timeout = setTimeout(later, wait); 648 }; 649 } 650 651 // Optional cleanup 652 destroy() { 653 this.stopPolling(); 654 if (this.chatQueueTimer) { 655 clearTimeout(this.chatQueueTimer); 656 this.chatQueueTimer = null; 657 } 658 this.chatQueue = []; 659 } 660} 661 662// CSS Styles (add to your stylesheet) 663const notificationStyles = ` 664.notifications-container { 665 position: fixed; top: 20px; right: 20px; z-index: 10000; max-width: 400px; 666} 667.notification { background: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); 668 margin-bottom: 10px; padding: 15px; display: flex; align-items: flex-start; border-left: 4px solid #667eea; animation: slideIn 0.3s ease-out; } 669.notification-high { border-left-color: #e53e3e; } 670.notification-medium { border-left-color: #d69e2e; } 671.notification-low { border-left-color: #38a169; } 672.notification-icon { font-size: 20px; margin-right: 12px; margin-top: 2px; } 673.notification-content { flex: 1; } 674.notification-title { font-weight: bold; margin-bottom: 5px; color: #2d3748; } 675.notification-message { color: #4a5568; font-size: 14px; line-height: 1.4; } 676.notification-time { font-size: 12px; color: #718096; margin-top: 5px; } 677.notification-close { background: none; border: none; font-size: 18px; cursor: pointer; color: #a0aec0; margin-left: 10px; } 678.notification-close:hover { color: #718096; } 679.fade-out { opacity: 0; transform: translateX(100%); transition: all 0.5s ease-in-out; } 680.custom-toast { position: fixed; bottom: 20px; right: 20px; background: #2d3748; color: white; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; transform: translateY(100%); transition: all 0.3s ease-in-out; z-index: 10001; max-width: 300px; } 681.custom-toast.show { opacity: 1; transform: translateY(0); } 682@keyframes slideIn { from { opacity: 0; transform: translateX(100%);} to { opacity: 1; transform: translateX(0);} } 683.notification-badge { position: absolute; top: -8px; right: -8px; background: #e53e3e; color: white; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; display: flex; align-items: center; justify-content: center; display: none; } 684#fyf-offline-bar { position:fixed; left:0; right:0; top:0; z-index:99999; 685 background:#2b2b2b; color:#fff; font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial; 686 padding:6px 10px; text-align:center; border-bottom:1px solid #444; display:none; } 687`; 688 689// ==================================================================== 690// SINGLE, CLEAN INITIALIZATION - NO CONFLICTS 691// ==================================================================== 692
693function initializeCompleteNotificationSystem() { 694 try { 695 console.log('ð Starting notification system initialization...'); 696 697 // Add styles first 698 if (!document.getElementById('fyf-notification-styles')) { 699 const styleSheet = document.createElement('style'); 700 styleSheet.id = 'fyf-notification-styles'; 701 styleSheet.textContent = notificationStyles; 702 document.head.appendChild(styleSheet); 703 } 704 705 // Create main system 706 if (typeof ProactiveNotifications === 'undefined') { 707 console.error('â ProactiveNotifications class not found!'); 708 createFallbackNotificationSystem(); 709 return; 710 } 711 712 const notificationSystem = new ProactiveNotifications(); 713 714 // Initialize with error handling 715 if (typeof notificationSystem.init === 'function') { 716 notificationSystem.init(); 717 } 718 719 // Store globally 720 window.notificationSystem = notificationSystem; 721 722 // Add fetchNow method for consistency 723 if (typeof notificationSystem.fetchNow !== 'function') { 724 notificationSystem.fetchNow = function(manual = true) { 725 return this.fetchNotifications(); 726 }.bind(notificationSystem); 727 } 728 729 // Ask for browser notification permission 730 if ('Notification' in window && Notification.permission === 'default') { 731 Notification.requestPermission().catch(()=>{}); 732 } 733 734 console.log('â Notification system fully initialized'); 735 736 } catch (error) { 737 console.error('â Error initializing notification system:', error); 738 createFallbackNotificationSystem(); 739 } 740} 741 742// Fallback system (preserved from original) 743function createFallbackNotificationSystem() { 744 console.log('ð Creating fallback notification system...'); 745 746 window.notificationSystem = { 747 showNotification: function(notification) { 748 console.log('ð Fallback notification:', notification); 749 // basic filter for smoke in fallback mode 750 const t = (notification.title || '').toLowerCase(); 751 const m = (notification.message || '').toLowerCase(); 752 if ( 753 t.includes('[smoke]') || t.includes('bridge chat smoke') || 754 t.includes('chat smoke') || t.includes('fyf in-app ping') || 755 (t === 'notification' && !m) 756 ) { 757 return; 758 } 759 this.mirrorNotificationToChat(notification); 760 }, 761 762 mirrorNotificationToChat: function(notification) { 763 const t = (notification.title || '').toLowerCase(); 764 const m = (notification.message || '').toLowerCase(); 765 if ( 766 t.includes('[smoke]') || t.includes('bridge chat smoke') || 767 t.includes('chat smoke') || t.includes('fyf in-app ping') || 768 (t === 'notification' && !m) 769 ) { 770 return; 771 } 772 773 const text = (notification.title ? notification.title + ' â ' : '') + (notification.message || ''); 774 try { 775 if (window.homeChat && typeof window.homeChat.addMessageToChat === 'function') { 776 window.homeChat.addMessageToChat(text, 'assistant'); 777 return; 778 } 779 const chatContainer = document.getElementById('aiChatMessages'); 780 if (chatContainer) { 781 const messageDiv = document.createElement('div'); 782 messageDiv.className = 'chat-message assistant-message'; 783 messageDiv.style.cssText = 'background: rgba(142, 196, 255, 0.1); border: 1px solid #8ec4ff; border-radius: 18px; padding: 14px 18px; margin: 10px 0; max-width: 85%;'; 784 messageDiv.textContent = text; 785 chatContainer.appendChild(messageDiv); 786 chatContainer.scrollTop = chatContainer.scrollHeight; 787 } 788 } catch(e) { 789 console.warn('Chat mirror failed:', e); 790 } 791 }, 792 793 fetchNotifications: async function() { 794 try { 795 const uid = (window.fyf_profile && window.fyf_profile.user_id) 796 || (window.fyfProfile && window.fyfProfile.user_id) 797 || window.FYF_USER_ID 798 || ''; 799if (!uid) return []; 800const response = await fetch('/gpt/proactive_in_app_bridge.php?as=json&user_id=' + encodeURIComponent(uid) + '&mark=1'); 801 const data = await response.json(); 802 if (data.success && data.alerts && data.alerts.length > 0) { 803 console.log(`ð Processing ${data.alerts.length} notifications`);
804 data.alerts.forEach(alert => this.showNotification(alert)); 805 } 806 return data; 807 } catch (error) { 808 console.error('Failed to fetch notifications:', error); 809 return { success: false, error: error.message }; 810 } 811 }, 812 813 fetchNow: function() { 814 return this.fetchNotifications(); 815 } 816 }; 817 818 console.log('â Fallback notification system created'); 819} 820 821// ==================================================================== 822// OFFLINE-RESILIENT BRIDGE WRAPPER (quiet banner, dedupe-aware) 823// ==================================================================== 824 825function attachResilientBridgeWrapper() { 826 if (!window.notificationSystem) return; 827 828 // Slim offline banner â now LOG ONLY (no visible bar) 829 let offlineBar; 830 function showOffline(text) { 831 // Disabled visual offline bar to avoid top-page message; log only 832 console.log(text || 'Youâre offline. Weâll retry automaticallyâ¦'); 833 } 834 835 function hideOffline() { 836 // no-op; bar not used 837 } 838 839 // JSONP helper with timeout (no Promises required) 840 function callBridgeJSONP({ userId, sinceISO, mark = 1, timeoutMs = 8000 }, onDone) { 841 const cbName = `fyfBridgeCb_${Date.now()}_${Math.random().toString(36).slice(2)}`; 842 let done = false, timer; 843 window[cbName] = function (resp) { 844 if (done) return; 845 done = true; 846 clearTimeout(timer); 847 try { onDone(null, resp); } finally { 848 try { delete window[cbName]; } catch (e) {} 849 if (scr && scr.parentNode) scr.parentNode.removeChild(scr); 850 } 851 }; 852 const scr = document.createElement('script'); 853 const qs = new URLSearchParams({ 854 user_id: String(userId || ''), 855 since: sinceISO || '', 856 mark: String(mark), 857 mode: 'live', 858 callback: cbName 859 }); 860 scr.src = `/gpt/proactive_in_app_bridge.php?${qs.toString()}`; 861 scr.onerror = function () { 862 if (done) return; 863 done = true; 864 clearTimeout(timer); 865 try { onDone(new Error('bridge-script-error')); } finally { 866 try { delete window[cbName]; } catch (e) {} 867 if (scr && scr.parentNode) scr.parentNode.removeChild(scr); 868 } 869 }; 870 timer = setTimeout(function () { 871 if (done) return; 872 done = true; 873 try { onDone(new Error('bridge-timeout')); } finally { 874 try { delete window[cbName]; } catch (e) {} 875 if (scr && scr.parentNode) scr.parentNode.removeChild(scr); 876 } 877 }, timeoutMs); 878 document.head.appendChild(scr); 879 } 880 881 // Backoff + state 882 let backoffMs = 2000; // starts at 2s 883 const backoffMaxMs = 60000; // caps at 60s 884 const resetBackoff = () => backoffMs = 2000; 885 const growBackoff = () => backoffMs = Math.min(backoffMaxMs, backoffMs * 2); 886 887 function resolveUserId() { 888 const win = window; 889 const fromFyf = win.fyf_profile && (win.fyf_profile.user_id || win.fyf_profile.login_name || win.fyf_profile.username); 890 const id = (win.userProfile && win.userProfile.user_id) || win.currentUserId || fromFyf || (win.fyf_profile && win.fyf_profile.user_id); 891 return id || null; 892 } 893 894 function computeSinceISO() { 895 // Prefer the bridge timestamp we last saw (avoids re-fetching large "sent" windows) 896 const lc = window.notificationSystem && window.notificationSystem.lastCheck; 897 if (lc) return String(lc); 898 // Fallback: last 2 hours 899 const d = new Date(Date.now() - 2 * 60 * 60 * 1000); 900 return d.toISOString(); 901 } 902 903 let retryTimer = null; 904 let isFetching = false; 905 906 function scheduleRetry(reason) { 907 console.log(`ð Retry in ${Math.round(backoffMs/1000)}s (${reason || 'backoff'})`); 908 clearTimeout(retryTimer); 909 retryTimer = setTimeout(() => resilientFetch(true), backoffMs); 910 growBackoff(); 911 } 912 913 function resilientFetch(manual) { 914 if (isFetching) return; // prevent overlap 915 try { if (window.notificationSystem) window.notificationSystem.chatMirrorCountThisFetch = 0; } catch(e) {} 916 const uid = resolveUserId(); 917 if (!uid) { 918 console.warn('notificationSystem: no user_id; skipping fetch'); 919 return; 920 } 921 922 if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
923 showOffline('Youâre offline. Waiting to reconnectâ¦'); 924 scheduleRetry('offline'); 925 return; 926 } 927 928 isFetching = true; 929 const sinceISO = computeSinceISO(); 930 931 try { 932 if (manual) console.log('ð¡ Fetching notifications (resilient)â¦'); 933 callBridgeJSONP({ userId: uid, sinceISO, mark: 1, timeoutMs: 8000 }, function (err, resp) { 934 isFetching = false; 935 if (err) { 936 showOffline('Network hiccup⦠retrying soon'); 937 scheduleRetry(err.message || 'jsonp-error'); 938 return; 939 } 940 hideOffline(); 941 resetBackoff(); 942 943 try { 944 const alerts = Array.isArray(resp && resp.alerts) ? resp.alerts : []; 945 if (alerts.length) { 946 alerts.forEach(a => { 947 // Pass id + metadata through so dedupe + chat filters work 948 window.notificationSystem.showNotification({ 949 id: a.id, 950 alert_type: a.alert_type || 'custom', 951 title: a.title || 'ð Notification', 952 message: a.message || '', 953 priority: a.priority || 'medium', 954 sent_at: a.happened_iso || new Date().toISOString(), 955 metadata: a.metadata || a.meta || null 956 }); 957 }); 958 } 959 } catch (renderErr) { 960 console.warn('Render error in resilient fetch:', renderErr); 961 } 962 }); 963 } catch (ex) { 964 isFetching = false; 965 showOffline('Network hiccup⦠retrying soon'); 966 scheduleRetry('exception'); 967 } 968 } 969 970 // Expose a manual "fetch now" 971 window.notificationSystem.fetchNow = function () { return resilientFetch(true); }; 972 973 // Replace the poller-facing fetch with the resilient version 974 const originalFetch = window.notificationSystem.fetchNotifications.bind(window.notificationSystem); 975 window.notificationSystem.fetchNotifications = function () { return resilientFetch(false); }; 976 977 // React to browser online/offline immediately 978 window.addEventListener('online', function () { 979 hideOffline(); 980 resetBackoff(); 981 clearTimeout(retryTimer); 982 setTimeout(() => resilientFetch(true), 150); 983 }); 984 985 window.addEventListener('offline', function () { 986 showOffline('Youâre offline. Waiting to reconnectâ¦'); 987 }); 988 989 console.log('â Resilient bridge wrapper attached'); 990} 991 992// ==================================================================== 993// GLOBAL HELPER FUNCTIONS (preserved) 994// ==================================================================== 995 996// Safe shims & exports 997(function(){ 998 try { 999 window.fetchNotifications = window.fetchNotifications || (async function(){ 1000 return window.notificationSystem ? window.notificationSystem.fetchNotifications() : {success:false}; 1001 }); 1002 1003 window.showNotification = window.showNotification || function(n){ 1004 if (window.notificationSystem) window.notificationSystem.showNotification(n); 1005 }; 1006 } catch(e) { 1007 console.warn('notification shim failed', e); 1008 } 1009})(); 1010 1011// Global force fetch helper for DevTools 1012window.forceNotificationFetch = function(manual = true) { 1013 try { 1014 if (!window.notificationSystem) { 1015 console.warn('notificationSystem not available, initializing...'); 1016 initializeCompleteNotificationSystem(); 1017 } 1018 1019 if (window.notificationSystem && typeof window.notificationSystem.fetchNow === 'function') { 1020 return window.notificationSystem.fetchNow(manual); 1021 } 1022 1023 if (window.notificationSystem && typeof window.notificationSystem.fetchNotifications === 'function') { 1024 return window.notificationSystem.fetchNotifications(); 1025 } 1026 1027 console.warn('forceNotificationFetch: No fetch method available'); 1028 return Promise.resolve({ success: false, error: 'System not ready' }); 1029 1030 } catch (error) { 1031 console.error('forceNotificationFetch error:', error); 1032 return Promise.resolve({ success: false, error: error.message }); 1033 } 1034}; 1035 1036// ==================================================================== 1037// MAIN INITIALIZATION - SINGLE ENTRY POINT 1038// ==================================================================== 1039
1040function startNotificationSystem() { 1041 // Wait for DOM to be ready 1042 if (document.readyState === 'loading') { 1043 document.addEventListener('DOMContentLoaded', function() { 1044 initializeCompleteNotificationSystem(); 1045 // Attach resilient wrapper after a short delay to ensure system is ready 1046 setTimeout(attachResilientBridgeWrapper, 100); 1047 }); 1048 } else { 1049 initializeCompleteNotificationSystem(); 1050 setTimeout(attachResilientBridgeWrapper, 100); 1051 } 1052} 1053 1054// Start the system 1055startNotificationSystem(); 1056 1057console.log('ð notifications.js loaded successfully');
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.