PageSourceSearch

https://workspace.sarv.com/firebase-messaging-sw.js

js sarv.com collected 2026-09-24 19:35:21 UTC 49,246 bytes, 1,431 lines download raw bytes

1/* eslint-disable no-restricted-globals */
2/* eslint-disable no-undef */
3// ================================================================
4// Firebase Cloud Messaging Service Worker - Advanced Version
5// ================================================================
6// Features:
7// 1. Push notification handling with retry logic
8// 2. Notification grouping and badge management
9// 3. Notification actions (Quick actions from notification)
10// 4. Analytics and tracking
11// 5. Rate limiting to prevent spam
12// 6. IndexedDB persistence for reliability
13// 7. Error handling and logging system
14// ================================================================
15
16importScripts("https://www.gstatic.com/firebasejs/11.5.0/firebase-app-compat.js");
17importScripts("https://www.gstatic.com/firebasejs/11.5.0/firebase-messaging-compat.js");
18
19// ================================================================
20// Configuration
21// ================================================================
22
23const firebaseConfig = {
24  apiKey: "AIzaSyCRoX9G8klhiQZJ68YABOJzuMyjJf5ncSI",
25  authDomain: "sarv-workspace365.firebaseapp.com",
26  projectId: "sarv-workspace365",
27  storageBucket: "sarv-workspace365.firebasestorage.app",
28  messagingSenderId: "788813090525",
29  appId: "1:788813090525:web:dcc718b7c57a5205b49534",
30  measurementId: "G-LMHSC73FZ9"
31};
32
33// Advanced configuration options
34const CONFIG = {
35  // Notification settings
36  MAX_NOTIFICATIONS_PER_MINUTE: 10,
37  MAX_RETRY_ATTEMPTS: 3,
38  RETRY_DELAY: 2000, // 2 seconds
39
40  // Storage keys
41  DB_NAME: "console_sw_db",
42  DB_VERSION: 3,
43  STORE_NOTIFICATIONS: "notifications",
44  STORE_ANALYTICS: "analytics",
45  STORE_BADGE: "badge",
46
47  // Storage caps — oldest entries are dropped when exceeded
48  MAX_ANALYTICS_ENTRIES: 200,
49  MAX_NOTIFICATION_ENTRIES: 100,
50
51  // Debug mode: auto-enabled on localhost, off in production.
52  // Can also be toggled at runtime via ENABLE_DEBUG / DISABLE_DEBUG messages.
53  DEBUG: self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1",
54};
55
56console.log("[MAIL-SW-BOOT] firebase-messaging-sw.js loaded", {
57  href: self.location.href,
58  hostname: self.location.hostname,
59});
60
61// ================================================================
62// Offline Cache Configuration
63// ================================================================
64
65// Offline app-shell caching is temporarily disabled because this worker is
66// registered at the app root and was serving stale assets on normal reloads.
67// Keep this code commented for now so it can be restored later if needed.
68//
69// const CACHE_VERSION = "v1";
70// const APP_SHELL_CACHE = `app-shell-${CACHE_VERSION}`;
71// const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
72//
73// // Core assets to pre-cache during install for offline support
74// const APP_SHELL_URLS = ["/", "/offline.html"];
75
76// ================================================================
77// Utility Functions
78// ================================================================
79
80/**
81 * Advanced logging system with levels
82 */
83const Logger = {
84  _log(level, ...args) {
85    if (!CONFIG.DEBUG && level === "debug") {
86      return;
87    }
88    const timestamp = new Date().toISOString();
89    const prefix = `[SW ${level.toUpperCase()}] ${timestamp}:`;
90    console[level === "error" ? "error" : "log"](prefix, ...args);
91  },
92
93  debug(...args) {
94    this._log("debug", ...args);
95  },
96
97  info(...args) {
98    this._log("info", ...args);
99  },
100
101  warn(...args) {
102    this._log("warn", ...args);
103  },
104
105  error(...args) {
106    this._log("error", ...args);
107  }
108};
109
110/**
111 * IndexedDB wrapper for persistent storage
112 */
113class StorageManager {
114  constructor() {
115    this.db = null;
116  }
117
118  async init() {
119    if (this.db) {
120      return this.db;
121    }
122
123    return new Promise((resolve, reject) => {
124      const request = indexedDB.open(CONFIG.DB_NAME, CONFIG.DB_VERSION);
125
126      request.onerror = () => {
127        Logger.error("IndexedDB open failed:", request.error);
128        reject(request.error);
129      };
130
131      request.onsuccess = () => {
132        this.db = request.result;
133        Logger.debug("IndexedDB opened successfully");
134        resolve(this.db);
135      };
136
137      request.onupgradeneeded = (event) => {
138        const db = event.target.result;
139
140        if (!db.objectStoreNames.contains(CONFIG.STORE_NOTIFICATIONS)) {
141          const notificationStore = db.createObjectStore(CONFIG.STORE_NOTIFICATIONS, { keyPath: "id", autoIncrement: true });
142          notificationStore.createIndex("timestamp", "timestamp", { unique: false });
143          notificationStore.createIndex("product", "product", { unique: false });
144          Logger.debug("Created notification store");
145        }
146
147        if (!db.objectStoreNames.contains(CONFIG.STORE_ANALYTICS)) {
148          const analyticsStore = db.createObjectStore(CONFIG.STORE_ANALYTICS, { keyPath: "id", autoIncrement: true });
149          analyticsStore.createIndex("type", "type", { unique: false });
150          analyticsStore.createIndex("timestamp", "timestamp", { unique: false });
151          Logger.debug("Created analytics store");
152        }
153
154        if (!db.objectStoreNames.contains(CONFIG.STORE_BADGE)) {
155          db.createObjectStore(CONFIG.STORE_BADGE, { keyPath: "id" });
156          Logger.debug("Created badge store");
157        }
158      };
159    });
160  }
161
162  async set(storeName, data) {
163    try {
164      await this.init();
165      return new Promise((resolve, reject) => {
166        const transaction = this.db.transaction([storeName], "readwrite");
167        const store = transaction.objectStore(storeName);
168        const request = store.put(data);
169
170        request.onsuccess = () => resolve(request.result);
171        request.onerror = () => reject(request.error);
172      });
173    } catch (error) {
174      Logger.error("Storage set error:", error);
175      throw error;
176    }
177  }
178
179  async get(storeName, key) {
180    try {
181      await this.init();
182      return new Promise((resolve, reject) => {
183        const transaction = this.db.transaction([storeName], "readonly");
184        const store = transaction.objectStore(storeName);
185        const request = store.get(key);
186
187        request.onsuccess = () => resolve(request.result);
188        request.onerror = () => reject(request.error);
189      });
190    } catch (error) {
191      Logger.error("Storage get error:", error);
192      return null;
193    }
194  }
195
196  async getAll(storeName) {
197    try {
198      await this.init();
199      return new Promise((resolve, reject) => {
200        const transaction = this.db.transaction([storeName], "readonly");
201        const store = transaction.objectStore(storeName);
202        const request = store.getAll();
203
204        request.onsuccess = () => resolve(request.result);
205        request.onerror = () => reject(request.error);
206      });
207    } catch (error) {
208      Logger.error("Storage getAll error:", error);
209      return [];
210    }
211  }
212
213  async delete(storeName, key) {
214    try {
215      await this.init();
216      return new Promise((resolve, reject) => {
217        const transaction = this.db.transaction([storeName], "readwrite");
218        const store = transaction.objectStore(storeName);
219        const request = store.delete(key);
220
221        request.onsuccess = () => resolve();
222        request.onerror = () => reject(request.error);
223      });
224    } catch (error) {
225      Logger.error("Storage delete error:", error);
226    }
227  }
228
229  async clear(storeName) {
230    try {
231      await this.init();
232      return new Promise((resolve, reject) => {
233        const transaction = this.db.transaction([storeName], "readwrite");
234        const store = transaction.objectStore(storeName);
235        const request = store.clear();
236
237        request.onsuccess = () => resolve();
238        request.onerror = () => reject(request.error);
239      });
240    } catch (error) {
241      Logger.error("Storage clear error:", error);
242    }
243  }
244
245  /**
246   * Trim a store to maxEntries by deleting the oldest records (by timestamp).
247   * Called after every write to prevent unbounded IndexedDB growth.
248   */
249  async trim(storeName, maxEntries) {
250    try {
251      await this.init();
252      return new Promise((resolve, reject) => {
253        const transaction = this.db.transaction([storeName], "readwrite");
254        const store = transaction.objectStore(storeName);
255        const request = store.getAll();
256
257        request.onsuccess = () => {
258          const records = request.result;
259          if (records.length <= maxEntries) {
260            resolve();
261            return;
262          }
263
264          // Sort oldest-first by timestamp, delete the excess
265          records.sort((a, b) => a.timestamp - b.timestamp);
266          const toDelete = records.slice(0, records.length - maxEntries);
267          Logger.debug(`Trimming ${toDelete.length} old entries from "${storeName}"`);
268
269          let pending = toDelete.length;
270          toDelete.forEach((record) => {
271            const del = store.delete(record.id);
272            del.onsuccess = () => { if (--pending === 0) resolve(); };
273            del.onerror = () => reject(del.error);
274          });
275        };
276
277        request.onerror = () => reject(request.error);
278      });
279    } catch (error) {
280      Logger.error("Storage trim error:", error);
281    }
282  }
283}
284
285const storage = new StorageManager();
286
287/**
288 * Rate limiter to prevent notification spam
289 */
290class RateLimiter {
291  constructor() {
292    this.timestamps = [];
293  }
294
295  isAllowed() {
296    const now = Date.now();
297    const oneMinuteAgo = now - 60000;
298
299    // Remove timestamps older than 1 minute
300    this.timestamps = this.timestamps.filter(t => t > oneMinuteAgo);
301
302    if (this.timestamps.length >= CONFIG.MAX_NOTIFICATIONS_PER_MINUTE) {
303      Logger.warn("Rate limit exceeded, notification blocked");
304      return false;
305    }
306
307    this.timestamps.push(now);
308    return true;
309  }
310
311  reset() {
312    this.timestamps = [];
313  }
314}
315
316const rateLimiter = new RateLimiter();
317
318/**
319 * Sound Manager
320 * - Per-product cooldown: prevents repeated sounds for the same notification type
321 * - Burst detection: prevents all queued sounds from playing at once (e.g. after Mac sleep/wake)
322 */
323const SoundManager = {
324  lastSoundByProduct: new Map(), // product -> last played timestamp
325  recentNotificationTimes: [],   // timestamps of recent notifications (for burst detection)
326
327  PRODUCT_COOLDOWN_MS: 8000, // same product type: only one sound per 8 seconds
328  BURST_WINDOW_MS: 3000,     // burst detection window
329  BURST_THRESHOLD: 2,        // if >= 2 notifications arrive within BURST_WINDOW_MS, it's a burst
330
331  canPlaySound(product) {
332    const now = Date.now();
333
334    // Clean entries outside burst window
335    this.recentNotificationTimes = this.recentNotificationTimes.filter(
336      (t) => now - t < this.BURST_WINDOW_MS
337    );
338
339    // Check burst — if already saw >= BURST_THRESHOLD notifications recently, skip sound
340    // (handles sleep/wake scenario where all queued notifications arrive at once)
341    if (this.recentNotificationTimes.length >= this.BURST_THRESHOLD) {
342      Logger.warn(
343        `🔊 [Sound] Burst detected (${this.recentNotificationTimes.length + 1} notifications in ${this.BURST_WINDOW_MS}ms) — skipping sound`
344      );
345      this.recentNotificationTimes.push(now);
346      return false;
347    }
348
349    // Track this notification for burst detection
350    this.recentNotificationTimes.push(now);
351
352    // Per-product cooldown check (Issue 1: back-to-back same type)
353    const lastForProduct = this.lastSoundByProduct.get(product) || 0;
354    if (now - lastForProduct < this.PRODUCT_COOLDOWN_MS) {
355      Logger.info(
356        `🔊 [Sound] Cooldown active for "${product}" (${Math.round((now - lastForProduct) / 1000)}s ago) — skipping sound`
357      );
358      return false;
359    }
360
361    return true;
362  },
363
364  markPlayed(product) {
365    this.lastSoundByProduct.set(product, Date.now());
366  },
367};
368
369/**
370 * Analytics tracker
371 */
372class AnalyticsTracker {
373  async track(eventType, data = {}) {
374    try {
375      const event = {
376        type: eventType,
377        timestamp: Date.now(),
378        data: data,
379      };
380
381      await storage.set(CONFIG.STORE_ANALYTICS, event);
382      await storage.trim(CONFIG.STORE_ANALYTICS, CONFIG.MAX_ANALYTICS_ENTRIES);
383      Logger.debug("Analytics tracked:", eventType, data);
384
385      // Send to server (could be implemented)
386      this._sendToServer(event);
387    } catch (error) {
388      Logger.error("Analytics tracking failed:", error);
389    }
390  }
391
392  _sendToServer(event) {
393    // Send analytics to server when online
394    // This could be implemented with fetch() or background sync
395    Logger.debug("Analytics event ready for server:", event);
396  }
397}
398
399const analytics = new AnalyticsTracker();
400
401// ================================================================
402// Firebase Initialization
403// ================================================================
404
405let messaging = null;
406
407try {
408  firebase.initializeApp(firebaseConfig);
409  messaging = firebase.messaging();
410  Logger.info("Firebase initialized successfully");
411} catch (error) {
412  Logger.error("Firebase initialization failed:", error);
413}
414
415// ================================================================
416// Notification Badge Management
417// ================================================================
418
419class BadgeManager {
420  constructor() {
421    this.count = 0;
422  }
423
424  /**
425   * Restore count from IndexedDB on SW restart so the badge survives termination.
426   */
427  async init() {
428    try {
429      const stored = await storage.get(CONFIG.STORE_BADGE, "count");
430      if (stored && typeof stored.value === "number") {
431        this.count = stored.value;
432        await this.update();
433        Logger.debug("Badge count restored from IndexedDB:", this.count);
434      }
435    } catch (error) {
436      Logger.error("Failed to restore badge count:", error);
437    }
438  }
439
440  async increment() {
441    this.count++;
442    await this.update();
443  }
444
445  async decrement() {
446    if (this.count > 0) {
447      this.count--;
448    }
449    await this.update();
450  }
451
452  async reset() {
453    this.count = 0;
454    await this.update();
455  }
456
457  async update() {
458    try {
459      // Persist count so it survives SW termination
460      await storage.set(CONFIG.STORE_BADGE, { id: "count", value: this.count });
461
462      if ("setAppBadge" in navigator) {
463        if (this.count > 0) {
464          await navigator.setAppBadge(this.count);
465        } else {
466          await navigator.clearAppBadge();
467        }
468        Logger.debug("Badge updated:", this.count);
469      }
470    } catch (error) {
471      Logger.error("Badge update failed:", error);
472    }
473  }
474}
475
476const badgeManager = new BadgeManager();
477
478// ================================================================
479// Notification Processing
480// ================================================================
481
482/**
483 * Display notification with retry logic
484 */
485async function displayNotificationWithRetry(title, options, retryCount = 0) {
486  try {
487    console.log("[MAIL-SW-1] showNotification:start", {
488      title,
489      body: options?.body,
490      icon: options?.icon,
491      product: options?.data?.product,
492      url: options?.data?.url,
493      tag: options?.tag,
494      retryCount,
495    });
496    await self.registration.showNotification(title, options);
497    console.log("[MAIL-SW-2] showNotification:success", {
498      title,
499      body: options?.body,
500      icon: options?.icon,
501      product: options?.data?.product,
502      url: options?.data?.url,
503      tag: options?.tag,
504    });
505    Logger.info("Notification displayed:", title);
506    await analytics.track("notification_displayed", { title, product: options.data?.product });
507    await badgeManager.increment();
508    return true;
509  } catch (error) {
510    console.log("[MAIL-SW-3] showNotification:error", {
511      title,
512      body: options?.body,
513      icon: options?.icon,
514      product: options?.data?.product,
515      url: options?.data?.url,
516      tag: options?.tag,
517      retryCount,
518      error: error?.message || error,
519      stack: error?.stack || null,
520    });
521    Logger.error(`Notification display failed (attempt ${retryCount + 1}):`, error);
522
523    if (retryCount < CONFIG.MAX_RETRY_ATTEMPTS) {
524      await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY));
525      return displayNotificationWithRetry(title, options, retryCount + 1);
526    }
527
528    await analytics.track("notification_failed", { title, error: error.message });
529    return false;
530  }
531}
532
533/**
534 * Process Meet product notifications with advanced features
535 */
536function processMeetNotification(payload) {
537  const notificationOptions = {
538    body: payload.data?.body || "You have a new meeting",
539    icon: payload.data?.icon || "/icons/default-icon.png",
540    badge: "/icons/badge-icon.png",
541    tag: `meet-${payload.messageId}`,
542    renotify: true,
543    requireInteraction: true, // Meeting notifications should persist
544    vibrate: [200, 100, 200], // Vibration pattern
545    data: {
546      url: payload.data?.redirectionPath || "/meet",
547      payload: payload,
548      playSound: true,
549      product: "meet",
550      timestamp: Date.now(),
551    },
552    actions: [
553      {
554        action: "join",
555        title: "Join Now",
556        icon: "/icons/join-icon.png"
557      },
558      {
559        action: "dismiss",
560        title: "Dismiss",
561        icon: "/icons/dismiss-icon.png"
562      }
563    ]
564  };
565
566  Logger.debug("Processing Meet notification:", payload.data?.title);
567  return displayNotificationWithRetry(
568    payload.data?.title || "New Meeting",
569    notificationOptions
570  );
571}
572
573/**
574 * Process default product notifications
575 */
576function processDefaultNotification(payload) {
577  const product = payload.data?.product || "default";
578  const notificationBody =
579    product.toLowerCase() === "chat"
580      ? payload.data?.body ||
581        payload.data?.message ||
582        payload.data?.messgae ||
583        payload.notification?.body ||
584        "You have a new notification"
585      : payload.data?.body || "You have a new notification";
586
587  console.log("[MAIL-SW-4] processDefaultNotification:start", {
588    product,
589    title: payload.data?.title,
590    body: notificationBody,
591    rawBody: payload.data?.body,
592    rawMessage: payload.data?.message,
593    rawMessgae: payload.data?.messgae,
594    rawNotificationBody: payload.notification?.body,
595    redirectionPath: payload.data?.redirectionPath,
596    notificationType: payload.data?.notificationType,
597    thridId: payload.data?.thridId,
598  });
599
600  // Web pushes are data-only (the backend no longer sends a notification block —
601  // that made the FCM SDK render the push itself on top of this handler, so every
602  // notification appeared twice). Grouping/priority intent therefore arrives in
603  // `data` and has to be applied here instead of by the SDK.
604  //
605  // `notificationTag` is only sent for collapse products (chat/teams), where the
606  // newest message should replace the previous one for that conversation. Mail has
607  // no tag on purpose, so mail notifications accumulate rather than overwrite.
608  const backendTag = payload.data?.notificationTag;
609  const collapseTag = backendTag || `${product}-${payload.messageId}`;
610
611  const notificationOptions = {
612    body: notificationBody,
613    icon:
614      payload.data?.icon ||
615      payload.notification?.icon ||
616      "/icons/default-icon.png",
617    badge: "/icons/badge-icon.png",
618    tag: collapseTag,
619    // renotify only has meaning alongside a tag: it re-alerts on replace instead of
620    // swapping the existing notification out silently.
621    renotify: payload.data?.renotify === "true",
622    // Deliberately NOT taken from the payload. macOS Notification Center does not
623    // support requireInteraction: Chrome accepts the notification but delivers it with
624    // no banner, straight into Notification Center, so honouring the backend's
625    // high-priority flag here silently killed every visible chat/mail banner on
626    // Chrome/macOS. Firefox ignores the option, which is why only Chrome regressed.
627    // Calls that genuinely need persistence set it themselves in processMeetNotification.
628    requireInteraction: false,
629    vibrate: [100, 50, 100],
630    data: {
631      url: payload.data?.redirectionPath || "/",
632      payload: payload,
633      playSound: true,
634      product: product,
635      timestamp: Date.now(),
636    },
637    actions: [
638      {
639        action: "view",
640        title: "View",
641        icon: "/icons/view-icon.png"
642      },
643      {
644        action: "dismiss",
645        title: "Dismiss",
646        icon: "/icons/dismiss-icon.png"
647      }
648    ]
649  };
650
651  Logger.debug("Processing default notification:", payload.data?.title);
652  console.log("[MAIL-SW-5] processDefaultNotification:options", {
653    product,
654    title: payload.data?.title || "New Notification",
655    options: notificationOptions,
656  });
657  return displayNotificationWithRetry(
658    payload.data?.title || "New Notification",
659    notificationOptions
660  );
661}
662
663/**
664 * Store notification for offline handling
665 */
666async function storeNotification(payload) {
667  try {
668    const notificationRecord = {
669      payload: payload,
670      timestamp: Date.now(),
671      product: payload.data?.product || "default",
672      processed: false,
673    };
674
675    await storage.set(CONFIG.STORE_NOTIFICATIONS, notificationRecord);
676    await storage.trim(CONFIG.STORE_NOTIFICATIONS, CONFIG.MAX_NOTIFICATION_ENTRIES);
677    Logger.debug("Notification stored for later processing");
678  } catch (error) {
679    Logger.error("Failed to store notification:", error);
680  }
681}
682
683/**
684 * Notify clients to play sound
685 */
686async function notifyClientsPlaySound(payload) {
687  try {
688    const product = payload?.data?.product || "default";
689    Logger.info("🔊 [Sound] Attempting to notify clients for sound playback");
690    Logger.info("🔊 [Sound] Payload product:", product);
691
692    // Guard: per-product cooldown + burst detection (sleep/wake)
693    if (!SoundManager.canPlaySound(product)) {
694      await analytics.track("sound_skipped", { product, timestamp: Date.now() });
695      return;
696    }
697
698    const clientsList = await clients.matchAll({ type: "window", includeUncontrolled: true });
699    // Only send to top-level windows, not embedded iframes
700    const topLevelClients = clientsList.filter((c) => !c.frameType || c.frameType === "top-level");
701
702    if (topLevelClients.length === 0) {
703      Logger.warn("🔊 [Sound] ⚠️ No active clients found - sound will not play");
704      await analytics.track("sound_no_clients", {
705        product,
706        timestamp: Date.now()
707      });
708      return;
709    }
710
711    Logger.info(`🔊 [Sound] ✓ Found ${topLevelClients.length} active client(s), sending sound message`);
712
713    // meet: send to ALL tabs so the call popup shows on whichever tab the user is on.
714    // chat/mail/other: send to ONE tab only — each tab plays sound independently,
715    // so sending to all would cause N sounds for N open tabs.
716    const isMeet = product === "meet";
717    const soundTargets = isMeet ? topLevelClients : [topLevelClients[0]];
718
719    soundTargets.forEach((client, index) => {
720      Logger.debug(`🔊 [Sound] Sending to client ${index + 1}/${soundTargets.length}, ID: ${client.id}`);
721      client.postMessage({
722        type: "FCM_NOTIFICATION",
723        payload: payload,
724        playSound: true,
725        timestamp: Date.now(),
726      });
727    });
728
729    // Mark as played only after successfully sending to clients
730    SoundManager.markPlayed(product);
731
732    Logger.info("🔊 [Sound] ✓ Sound notification message sent successfully to all clients");
733
734    await analytics.track("sound_notification_sent", {
735      product,
736      clientCount: soundTargets.length,
737      timestamp: Date.now()
738    });
739  } catch (error) {
740    Logger.error("🔊 [Sound] ❌ Failed to notify clients for sound:", error);
741    await analytics.track("sound_notification_failed", {
742      product: payload?.data?.product,
743      error: error.message,
744      timestamp: Date.now()
745    });
746  }
747}
748
749// ================================================================
750// Service Worker Lifecycle Events
751// ================================================================
752
753self.addEventListener("install", (event) => {
754  Logger.info("Service Worker installing");
755
756  event.waitUntil(
757    Promise.all([
758      self.skipWaiting(),
759      storage.init(),
760      // Pre-cache app shell for offline support
761      // caches.open(APP_SHELL_CACHE).then((cache) => {
762      //   Logger.info("[Cache] Pre-caching app shell");
763      //   return cache.addAll(APP_SHELL_URLS);
764      // }).catch((err) => {
765      //   Logger.warn("[Cache] App shell pre-cache failed (non-fatal):", err);
766      // }),
767    ]).then(() => {
768      Logger.info("Service Worker installed successfully");
769    })
770  );
771});
772
773self.addEventListener("activate", (event) => {
774  Logger.info("Service Worker activating");
775
776  // const validCaches = [APP_SHELL_CACHE, DYNAMIC_CACHE];
777
778  event.waitUntil(
779    Promise.all([
780      self.clients.claim(),
781      storage.init(),
782      badgeManager.init(),
783      // Remove outdated caches
784      // caches.keys().then((cacheNames) =>
785      //   Promise.all(
786      //     cacheNames
787      //       .filter((name) => !validCaches.includes(name))
788      //       .map((name) => {
789      //         Logger.info(`[Cache] Deleting old cache: ${name}`);
790      //         return caches.delete(name);
791      //       })
792      //   )
793      // ),
794    ]).then(async () => {
795      Logger.info("Service Worker activated successfully");
796      await analytics.track("sw_activated");
797    })
798  );
799});
800
801// ================================================================
802// Fetch Handler (Offline Caching)
803// ================================================================
804
805// Offline fetch interception is temporarily disabled because this worker was
806// caching app assets with a cache-first strategy, which caused stale bundles
807// to survive normal reloads. Keep the implementation commented for now.
808//
809// self.addEventListener("fetch", (event) => {
810//   const { request } = event;
811//   const url = new URL(request.url);
812//
813//   // Only handle GET requests from the same origin
814//   if (request.method !== "GET") return;
815//   if (url.origin !== self.location.origin) return;
816//   if (url.protocol === "chrome-extension:") return;
817//
818//   // API calls — network first, fall back to cached response
819//   if (url.pathname.startsWith("/api/")) {
820//     event.respondWith(networkFirst(request));
821//     return;
822//   }
823//
824//   // PDFs — cache first (3.7 MB of guide/crf PDFs; rare changes, big download savings)
825//   if (url.pathname.endsWith(".pdf")) {
826//     event.respondWith(cacheFirst(request));
827//     return;
828//   }
829//
830//   // Static assets (JS, CSS, images, fonts) — cache first, fetch on miss
831//   if (
832//     request.destination === "script" ||
833//     request.destination === "style" ||
834//     request.destination === "image" ||
835//     request.destination === "font"
836//   ) {
837//     event.respondWith(cacheFirst(request));
838//     return;
839//   }
840//
841//   // HTML navigation — network first, serve offline page when offline
842//   if (request.mode === "navigate") {
843//     event.respondWith(navigateFetch(request));
844//     return;
845//   }
846// });
847//
848// async function cacheFirst(request) {
849//   const cached = await caches.match(request);
850//   if (cached) return cached;
851//   try {
852//     const response = await fetch(request);
853//     if (response.ok) {
854//       const cache = await caches.open(DYNAMIC_CACHE);
855//       cache.put(request, response.clone());
856//     }
857//     return response;
858//   } catch (err) {
859//     Logger.warn("[Cache] cacheFirst fetch failed:", err);
860//     return new Response("Offline", { status: 503, statusText: "Service Unavailable" });
861//   }
862// }
863//
864// async function networkFirst(request) {
865//   try {
866//     const response = await fetch(request);
867//     if (response.ok) {
868//       const cache = await caches.open(DYNAMIC_CACHE);
869//       cache.put(request, response.clone());
870//     }
871//     return response;
872//   } catch (err) {
873//     Logger.warn("[Cache] networkFirst falling back to cache:", err);
874//     const cached = await caches.match(request);
875//     if (cached) return cached;
876//     return new Response(
877//       JSON.stringify({ offline: true, error: "No network connection" }),
878//       { status: 503, headers: { "Content-Type": "application/json" } }
879//     );
880//   }
881// }
882//
883// async function navigateFetch(request) {
884//   try {
885//     return await fetch(request);
886//   } catch (err) {
887//     Logger.warn("[Cache] Navigation offline, serving fallback:", err);
888//     return (
889//       (await caches.match(request)) ||
890//       (await caches.match("/")) ||
891//       (await caches.match("/offline.html")) ||
892//       new Response("<h1>You are offline</h1>", { headers: { "Content-Type": "text/html" } })
893//     );
894//   }
895// }
896
897// ================================================================
898// Push Notification Handler
899// ================================================================
900
901// Kept in exact sync with isCallEndedPayload() in
902// frontend/packages/console-spinner-embedded/src/hooks/useCallNotifications.jsx —
903// this runs in the service worker so it can't import that copy directly, but any
904// signal added/removed here must be mirrored there (and vice versa), otherwise one
905// context can decide "ended" while the other doesn't, leaving the popup stuck open
906// in whichever tab disagreed.
907function isCallEndedPayload(payload) {
908  const data = payload.data || {};
909  const title    = (data.title          || "").toLowerCase();
910  const body     = (data.body           || "").toLowerCase();
911  const status   = (data.callStatus     || "").toLowerCase();
912  const attendee = (data.attendeeStatus || "").toLowerCase();
913
914  const result = (
915    ["ended", "notanswered", "not_answered", "missed", "noanswer"].includes(status) ||
916    ["accept", "accepted", "reject", "rejected"].includes(attendee) ||
917    title === "call ended" ||
918    title === "call response" ||
919    title === "missed call" ||
920    title === "call missed" ||
921    body === "the call has ended" ||
922    body === "user has responded to the call"
923  );
924
925  console.warn("[SW-Push] isCallEndedPayload:", { title, body, status, attendee, result });
926  return result;
927}
928
929if (messaging) {
930  messaging.onBackgroundMessage(async (payload) => {
931    console.log(payload,"payload at line 931  starting function beofre any chnges")
932    const product = (payload.data?.product || "default").toLowerCase();
933    const title   = payload.data?.title;
934    const body    = payload.data?.body;
935
936    console.warn("[SW-Push] onBackgroundMessage received", {
937      product,
938      title,
939      body,
940      roomId:    payload.data?.roomId,
941      callType:  payload.data?.callType,
942      messageId: payload.messageId,
943      fullData:  payload.data,
944    });
945
946    // Skip OS notification and dismiss in-app popup for call ended / responded
947    if (isCallEndedPayload(payload)) {
948      console.warn("[SW-Push] Call ended/responded — closing OS notification + dismissing in-app popup", { title, body });
949
950      // Close any existing OS call notification for this room (works even when user
951      // hasn't touched the notification — this is the only code path that can do it)
952      const endedRoomId = payload.data?.roomId;
953      try {
954        const existing = await self.registration.getNotifications();
955        existing.forEach((notif) => {
956          if (notif.data?.product !== "meet") return;
957          const notifRoomId = notif.data?.payload?.data?.roomId;
958          if (!endedRoomId || !notifRoomId || String(notifRoomId) === String(endedRoomId)) {
959            notif.close();
960            Logger.info("[SW-Push] Closed OS call notification for roomId:", endedRoomId);
961          }
962        });
963      } catch (err) {
964        Logger.error("[SW-Push] Failed to close OS notification:", err);
965      }
966
967      // Also tell any open app tabs to dismiss their in-app popup
968      const clientsList = await clients.matchAll({ type: "window", includeUncontrolled: true });
969      clientsList.forEach((client) => {
970        client.postMessage({ type: "FCM_NOTIFICATION", payload, playSound: false });
971      });
972      return;
973    }
974
975    Logger.info("📩 [Push] Background message received");
976    Logger.info("📩 [Push] Payload:", JSON.stringify(payload, null, 2));
977    Logger.info("📩 [Push] Product type:", product);
978    Logger.info("📩 [Push] Timestamp:", new Date().toISOString());
979    console.log("[MAIL-SW-6] onBackgroundMessage:received", {
980      product,
981      title,
982      body,
983      message: payload.data?.message,
984      messgae: payload.data?.messgae,
985      notificationBody: payload.notification?.body,
986      notificationType: payload.data?.notificationType,
987      thridId: payload.data?.thridId,
988    });
989
990    // Silent templates (e.g. chat TYPING) must not surface anything. Web payloads are
991    // data-only now, so there is no notification block for the SDK to omit — the intent
992    // arrives as data.silent and has to be honoured here. Checked BEFORE the rate
993    // limiter so silent pings never consume the visible-notification budget.
994    if (payload.data?.silent === "true") {
995      Logger.info("📩 [Push] Silent notification — no OS notification, no sound");
996      return;
997    }
998
999    // Rate limiting check
1000    if (!rateLimiter.isAllowed()) {
1001      Logger.warn("📩 [Push] ⚠️ Notification blocked by rate limiter (too many notifications)");
1002      await analytics.track("notification_rate_limited", { payload });
1003      return;
1004    }
1005
1006    Logger.info("📩 [Push] ✓ Rate limit check passed");
1007
1008    // Store notification for analytics/history
1009    await storeNotification(payload);
1010
1011    let notificationPromise;
1012
1013    // Process based on product type
1014    Logger.info(`📩 [Push] Processing notification for product: ${product}`);
1015    switch (product) {
1016      case "meet":
1017        Logger.debug("📩 [Push] Using Meet notification processor");
1018        notificationPromise = processMeetNotification(payload);
1019        break;
1020      default:
1021        Logger.debug("📩 [Push] Using default notification processor");
1022        notificationPromise = processDefaultNotification(payload);
1023        break;
1024    }
1025
1026    // Wait for the OS notification to be shown before triggering sound,
1027    // so the user sees the notification and hears the sound at the same time.
1028    await notificationPromise;
1029
1030    // Notify clients to play sound (THIS IS CRITICAL FOR SOUND)
1031    Logger.info("📩 [Push] Now sending sound notification to clients...");
1032    await notifyClientsPlaySound(payload);
1033
1034    Logger.info("📩 [Push] ✓ Background message processing complete");
1035
1036    // For meet calls: keep the SW alive for 30 seconds and auto-dismiss the OS
1037    // notification if the user hasn't interacted with it (accepted or declined).
1038    // This covers the case where the app is in the background or not open at all.
1039    if (product === "meet") {
1040      const meetTag = `meet-${payload.messageId}`;
1041      await new Promise((resolve) => {
1042        setTimeout(async () => {
1043          try {
1044            const notifs = await self.registration.getNotifications({ tag: meetTag });
1045            notifs.forEach((n) => {
1046              n.close();
1047              Logger.info("[SW] Auto-dismissed OS call notification after 30s, tag:", meetTag);
1048            });
1049          } catch (err) {
1050            Logger.error("[SW] Auto-dismiss error:", err);
1051          }
1052          resolve();
1053        }, 30_000);
1054      });
1055    }
1056  });
1057} else {
1058  Logger.error("📩 [Push] ❌ Firebase messaging not initialized - push notifications will not work");
1059}
1060
1061// ================================================================
1062// Notification Click Handler (Advanced)
1063// ================================================================
1064
1065self.addEventListener("notificationclick", (event) => {
1066  Logger.info("Notification clicked:", event.action);
1067
1068  event.notification.close();
1069
1070  const notificationData = event.notification.data || {};
1071  const action = event.action;
1072
1073  // Track click analytics
1074  analytics.track("notification_clicked", {
1075    action: action,
1076    product: notificationData.product,
1077    tag: event.notification.tag,
1078  });
1079
1080  // Decrement badge
1081  badgeManager.decrement();
1082
1083  // Handle notification actions
1084  if (action === "dismiss") {
1085    Logger.info("Notification dismissed by user");
1086    return;
1087  }
1088console.log(notificationData,"notificationData at line 1080")
1089  // Determine target URL based on action.
1090  // `.url` is what THIS file's own processDefaultNotification()/processMeetNotification()
1091  // set when they build the notification (via onBackgroundMessage). But when the FCM
1092  // payload includes a `notification` block, Chrome auto-displays it NATIVELY and
1093  // bypasses onBackgroundMessage entirely — so event.notification.data is instead
1094  // whatever the raw payload's `notification.data` was, which uses `redirectionPath`
1095  // (and `click_action`), not `url`. Falling back to it here is the only frontend-side
1096  // fix possible for that case — the auto-display itself can't be intercepted from JS.
1097  let targetUrl = notificationData.url || notificationData.redirectionPath || "/";
1098  console.log("[MAIL-SW-7] notificationclick:start", {
1099    action,
1100    product: notificationData.product,
1101    targetUrl,
1102    payload: notificationData.payload?.data || null,
1103  });
1104
1105  if (action === "join" && notificationData.product === "meet") {
1106    event.waitUntil(
1107      clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
1108        const d = notificationData.payload?.data || {};
1109
1110        // Resolve accountId from the payload or the redirectionPath URL
1111        const resolveAccountIdFromUrl = (url) => {
1112          try {
1113            const { pathname } = new URL(url, self.location.origin);
1114            const segments = pathname.split("/").filter(Boolean);
1115            const accountMarkerIndex = segments.indexOf("u");
1116            return segments[accountMarkerIndex + 1] || "0";
1117          } catch {
1118            return "0";
1119          }
1120        };
1121        const fallbackAccountId =
1122          d.accountId ||
1123          d.activeAccount ||
1124          d.active_account ||
1125          resolveAccountIdFromUrl(d.redirectionPath || notificationData.url || self.location.origin);
1126
1127        const target = clientList.find((c) => c.url.startsWith(self.location.origin));
1128        if (target) {
1129          // Focus the existing tab and let the app handle navigation via SW_CALL_ACCEPTED.
1130          // The app builds the correct meet URL (with accountId, redirectionPath, etc.)
1131          // and opens it in a new tab — more reliable than SW-side navigate().
1132          await target.focus();
1133          target.postMessage({ type: "SW_CALL_ACCEPTED", payload: notificationData.payload });
1134          Logger.info("Sent SW_CALL_ACCEPTED to app for meet join");
1135        } else {
1136          // No open tab — open the meet join URL directly in a new window
1137          const joinUrl = d.roomId
1138            ? `/meet/u/${encodeURIComponent(fallbackAccountId)}/join_meet?roomId=${encodeURIComponent(d.roomId)}&autoJoin=true${d.chatId ? `&chatId=${encodeURIComponent(d.chatId)}` : ""}`
1139            : (notificationData.url || "/meet");
1140          await clients.openWindow(joinUrl);
1141          Logger.info("Opened new meet window", { joinUrl });
1142        }
1143      })
1144    );
1145    return;
1146  } else if (action === "view") {
1147    // View action — same Chrome-native-auto-display fallback as above.
1148    targetUrl = notificationData.url || notificationData.redirectionPath || "/";
1149  }
1150
1151  // Handle navigation for non-meet actions
1152  event.waitUntil(
1153    clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
1154      // Try to find an already open tab with matching URL
1155      for (const client of clientList) {
1156        const clientUrl = new URL(client.url);
1157        const targetUrlObj = new URL(targetUrl, self.location.origin);
1158
1159        if (clientUrl.pathname === targetUrlObj.pathname) {
1160          const needsNavigation = clientUrl.href !== targetUrlObj.href;
1161          let navigatedViaSw = false;
1162
1163          if (needsNavigation && typeof client.navigate === "function") {
1164            try {
1165              await client.navigate(targetUrlObj.href);
1166              navigatedViaSw = true;
1167            } catch (err) {
1168              Logger.warn("client.navigate() failed, falling back to postMessage:", err);
1169            }
1170          }
1171
1172          await client.focus();
1173
1174          // Send message to stop sound if playing
1175          client.postMessage({
1176            type: "STOP_NOTIFICATION_SOUND"
1177          });
1178
1179          // Fallback for browsers without WindowClient.navigate() (Firefox, Safari)
1180          // or where it silently failed — the app listens for this and does
1181          // window.location.href = path, so the redirect still happens.
1182          if (needsNavigation && !navigatedViaSw) {
1183            client.postMessage({
1184              type: "NAVIGATE",
1185              path: targetUrl,
1186              action: action,
1187            });
1188          }
1189
1190          Logger.info("Focused existing tab");
1191          return client;
1192        }
1193      }
1194
1195      // If no matching tab, focus any tab from our origin
1196      const sameOriginClient = clientList.find((client) =>
1197        client.url.startsWith(self.location.origin)
1198      );
1199
1200      if (sameOriginClient) {
1201        // This is the "user is on a completely different product's tab" case
1202        // (e.g. on Drive, notification is for Chat) — no pathname matched above.
1203        // try/catch is required: without it, a thrown/rejected navigate() here
1204        // aborts this whole async callback, so focus() and the postMessages
1205        // below never run either — the click silently does nothing.
1206        if (typeof sameOriginClient.navigate === "function") {
1207          try {
1208            await sameOriginClient.navigate(new URL(targetUrl, self.location.origin).href);
1209          } catch (err) {
1210            Logger.warn("client.navigate() failed on cross-product redirect:", err);
1211          }
1212        }
1213
1214        await sameOriginClient.focus();
1215
1216        // Send navigation message
1217        sameOriginClient.postMessage({
1218          type: "STOP_NOTIFICATION_SOUND"
1219        });
1220
1221        // ALWAYS also send this, even after a navigate() that didn't throw —
1222        // on Firefox specifically, navigating to a tab that's on a different
1223        // product/page has been observed to resolve without error yet not
1224        // actually move the page. The app's own NAVIGATE handler
1225        // (window.location.href) is a safe, idempotent backstop: harmless if
1226        // navigate() already worked, the only real fix when it silently didn't.
1227        sameOriginClient.postMessage({
1228          type: "NAVIGATE",
1229          path: targetUrl,
1230          action: action,
1231        });
1232
1233        Logger.info("Navigated existing tab");
1234        return sameOriginClient;
1235      }
1236
1237      // As last resort, open a new window
1238      Logger.info("Opening new window");
1239      return clients.openWindow(targetUrl);
1240    })
1241  );
1242});
1243
1244// ================================================================
1245// Message Handler (Communication with Main Thread)
1246// ================================================================
1247
1248self.addEventListener("message", async (event) => {
1249  const messageType = event.data?.type;
1250
1251  Logger.debug("Message received:", messageType, event.data);
1252
1253  switch (messageType) {
1254    case "RESET_BADGE":
1255      // Reset notification badge
1256      await badgeManager.reset();
1257      break;
1258
1259    case "GET_NOTIFICATION_COUNT":
1260      // Get unread notification count
1261      const notifications = await storage.getAll(CONFIG.STORE_NOTIFICATIONS);
1262      const unreadCount = notifications.filter(n => !n.read).length;
1263
1264      event.ports[0].postMessage({
1265        type: "NOTIFICATION_COUNT",
1266        count: unreadCount,
1267      });
1268      break;
1269
1270    case "CLEAR_NOTIFICATIONS":
1271      // Clear notification history
1272      await storage.clear(CONFIG.STORE_NOTIFICATIONS);
1273      await badgeManager.reset();
1274      Logger.info("Notification history cleared");
1275      break;
1276
1277    case "GET_ANALYTICS":
1278      // Get analytics data
1279      const analyticsData = await storage.getAll(CONFIG.STORE_ANALYTICS);
1280      event.ports[0].postMessage({
1281        type: "ANALYTICS_DATA",
1282        data: analyticsData,
1283      });
1284      break;
1285
1286    case "ENABLE_DEBUG":
1287      // Enable debug mode
1288      CONFIG.DEBUG = true;
1289      Logger.info("Debug mode enabled");
1290      break;
1291
1292    case "DISABLE_DEBUG":
1293      // Disable debug mode
1294      CONFIG.DEBUG = false;
1295      Logger.info("Debug mode disabled");
1296      break;
1297
1298    case "DISMISS_CALL_NOTIFICATION": {
1299      // Close the OS/browser notification for a specific call (sent when user
1300      // accepts, declines, or the call ends via the in-app popup).
1301      const dismissRoomId = event.data?.roomId;
1302      Logger.info("🔔 [Dismiss] Dismissing call notification for roomId:", dismissRoomId);
1303      try {
1304        const allNotifications = await self.registration.getNotifications();
1305        let dismissed = 0;
1306        allNotifications.forEach((notif) => {
1307          // Only target meet notifications
1308          if (notif.data?.product !== "meet") return;
1309          // If a specific roomId was provided, match it; otherwise close all meet notifications
1310          const notifRoomId = notif.data?.payload?.data?.roomId;
1311          if (!dismissRoomId || !notifRoomId || String(notifRoomId) === String(dismissRoomId)) {
1312            notif.close();
1313            dismissed++;
1314          }
1315        });
1316        Logger.info(`🔔 [Dismiss] Closed ${dismissed} call notification(s) for roomId: ${dismissRoomId}`);
1317        await analytics.track("call_notification_dismissed", { roomId: dismissRoomId, dismissed });
1318      } catch (error) {
1319        Logger.error("🔔 [Dismiss] Failed to dismiss call notification:", error);
1320      }
1321      break;
1322    }
1323
1324    case "STOP_NOTIFICATION_SOUND":
1325      // Broadcast stop sound message to all clients
1326      Logger.info("🔇 [Sound] Received request to stop notification sounds");
1327      try {
1328        const clientsList = await clients.matchAll({ type: "window", includeUncontrolled: true });
1329
1330        Logger.info(`🔇 [Sound] Broadcasting stop message to ${clientsList.length} client(s)`);
1331
1332        clientsList.forEach((client) => {
1333          client.postMessage({
1334            type: "STOP_NOTIFICATION_SOUND",
1335            timestamp: Date.now()
1336          });
1337        });
1338
1339        Logger.info("🔇 [Sound] ✓ Stop sound message broadcasted to all clients");
1340
1341        await analytics.track("notification_sound_stopped", {
1342          clientCount: clientsList.length,
1343          timestamp: Date.now()
1344        });
1345      } catch (error) {
1346        Logger.error("🔇 [Sound] Failed to broadcast stop sound message:", error);
1347      }
1348      break;
1349
1350    default:
1351      Logger.warn("Unknown message type:", messageType);
1352  }
1353});
1354
1355// ================================================================
1356// Background Sync (for offline notification processing)
1357// ================================================================
1358
1359self.addEventListener("sync", async (event) => {
1360  Logger.info("Background sync triggered:", event.tag);
1361
1362  if (event.tag === "sync-notifications") {
1363    event.waitUntil(
1364      (async () => {
1365        try {
1366          const notifications = await storage.getAll(CONFIG.STORE_NOTIFICATIONS);
1367          const unprocessed = notifications.filter(n => !n.processed);
1368
1369          Logger.info(`Processing ${unprocessed.length} unprocessed notifications`);
1370
1371          for (const notification of unprocessed) {
1372            // Process notification
1373            const product = notification.product || "default";
1374
1375            if (product === "meet") {
1376              await processMeetNotification(notification.payload);
1377            } else {
1378              await processDefaultNotification(notification.payload);
1379            }
1380
1381            // Mark as processed
1382            notification.processed = true;
1383            await storage.set(CONFIG.STORE_NOTIFICATIONS, notification);
1384          }
1385
1386          await analytics.track("sync_notifications_completed", {
1387            count: unprocessed.length
1388          });
1389        } catch (error) {
1390          Logger.error("Background sync failed:", error);
1391        }
1392      })()
1393    );
1394  }
1395});
1396
1397// ================================================================
1398// Error Handler
1399// ================================================================
1400
1401self.addEventListener("error", (event) => {
1402  Logger.error("Service Worker error:", event.error);
1403  analytics.track("sw_error", {
1404    message: event.error?.message,
1405    stack: event.error?.stack,
1406  });
1407});
1408
1409self.addEventListener("unhandledrejection", (event) => {
1410  const msg = event.reason?.message || String(event.reason || "");
1411  // Firebase SDK internally tries to cache notification resources; suppress
1412  // the harmless 206 (Partial Content) cache error it occasionally throws.
1413  if (msg.includes("206") || msg.toLowerCase().includes("partial response")) {
1414    event.preventDefault();
1415    Logger.warn("Suppressed 206 partial-response cache error (Firebase SDK internal)");
1416    return;
1417  }
1418  Logger.error("Unhandled promise rejection:", event.reason);
1419  analytics.track("sw_unhandled_rejection", {
1420    reason: event.reason,
1421  });
1422});
1423
1424// ================================================================
1425// Initialization
1426// ================================================================
1427
1428Logger.info("=".repeat(60));
1429Logger.info("Firebase Cloud Messaging Service Worker - Advanced Version");
1430Logger.info("Features: Push Notifications, Auto Logout, Analytics, Rate Limiting");
1431Logger.info("=".repeat(60));

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.