PageSourceSearch

https://findyourfate.com/gpt/notifications.js?v=restore_proactive_20260516

js findyourfate.com collected 2026-10-02 07:09:55 UTC 38,013 bytes, 1,057 lines download raw bytes

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.