Save the timestamp from the last read notification in a IndexedDB
authorCyperghost <olaf_schmitz_1@t-online.de>
Mon, 28 Oct 2024 11:12:23 +0000 (12:12 +0100)
committerCyperghost <olaf_schmitz_1@t-online.de>
Mon, 28 Oct 2024 11:12:23 +0000 (12:12 +0100)
com.woltlab.wcf/templates/headIncludeJavaScript.tpl
ts/WoltLabSuite/Core/BootstrapFrontend.ts
ts/WoltLabSuite/Core/Notification/ServiceWorker.ts
wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js
wcfsetup/install/files/js/WoltLabSuite/Core/Notification/ServiceWorker.js
wcfsetup/install/files/lib/system/user/notification/UserNotificationHandler.class.php
wcfsetup/install/files/service-worker/index.php

index 3bb9f1411107d40eca9f8fa5a39bc279d9564794..d587217e645b82b763410ba4c1a30a93e3c58472 100644 (file)
@@ -82,6 +82,7 @@ window.addEventListener('pageshow', function(event) {
                                publicKey: '{@SERVICE_WORKER_PUBLIC_KEY|encodeJS}',
                                serviceWorkerJsUrl: '{$__wcf->getPath('wcf')}service-worker/',
                                registerUrl: '{link controller="RegisterServiceWorker"}{/link}',
+                               lastReadNotification: {$__wcf->getUserNotificationHandler()->getLastReadNotificationTime()}
                        },
                        {/if}
                        dynamicColorScheme: {if $__wcf->getStyleHandler()->getColorScheme() === 'system'}true{else}false{/if},
index be6881d2a14f2ed674d44be7efb7e208cbbb7c25..3e8ae0d61fc8e4b9e515b6d1ad46527eea7ff877 100644 (file)
@@ -29,6 +29,7 @@ interface BootstrapOptions {
     publicKey: string;
     serviceWorkerJsUrl: string;
     registerUrl: string;
+    lastReadNotification: number;
   };
   dynamicColorScheme: boolean;
   endpointUserPopover: string;
@@ -114,6 +115,7 @@ export function setup(options: BootstrapOptions): void {
         options.serviceWorker.publicKey,
         options.serviceWorker.serviceWorkerJsUrl,
         options.serviceWorker.registerUrl,
+        options.serviceWorker.lastReadNotification,
       );
     }
   }
index f4a29fd0f560d70a8f767a0f55f6f0f66ab2f91a..5e52798d912e12a0537a413f5b2b0cac2ff94951 100644 (file)
@@ -95,6 +95,13 @@ class ServiceWorker {
     }
     return outputArray;
   }
+
+  public updateLastNotificationTime(timestamp: number): void {
+    window.navigator.serviceWorker.controller?.postMessage({
+      type: "SAVE_LAST_NOTIFICATION_TIMESTAMP",
+      timestamp: timestamp,
+    });
+  }
 }
 
 export function serviceWorkerSupported(): boolean {
@@ -120,16 +127,26 @@ export function serviceWorkerSupported(): boolean {
   return true;
 }
 
-export function setup(publicKey: string, serviceWorkerJsUrl: string, registerUrl: string): void {
+export function setup(
+  publicKey: string,
+  serviceWorkerJsUrl: string,
+  registerUrl: string,
+  lastReadNotification: number,
+): void {
   if (!serviceWorkerSupported()) {
     return;
   }
   _serviceWorker = new ServiceWorker(publicKey, serviceWorkerJsUrl, registerUrl);
   if (Notification.permission === "granted") {
     registerServiceWorker();
+    _serviceWorker.updateLastNotificationTime(lastReadNotification);
   }
 }
 
 export function registerServiceWorker(): void {
   void _serviceWorker?.register();
 }
+
+export function updateLastNotificationTime(timestamp: number): void {
+  _serviceWorker?.updateLastNotificationTime(timestamp);
+}
index 2ec14797d0b8fd1bf27d135138a78bc860fa6701..e5eafebf94b891d685eeb640d454d632396b5f10 100644 (file)
@@ -78,7 +78,7 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui
         if (User_1.default.userId) {
             UiFeedDialog.setup();
             if (options.serviceWorker) {
-                (0, ServiceWorker_1.setup)(options.serviceWorker.publicKey, options.serviceWorker.serviceWorkerJsUrl, options.serviceWorker.registerUrl);
+                (0, ServiceWorker_1.setup)(options.serviceWorker.publicKey, options.serviceWorker.serviceWorkerJsUrl, options.serviceWorker.registerUrl, options.serviceWorker.lastReadNotification);
             }
         }
         (0, LazyLoader_1.whenFirstSeen)("woltlab-core-reaction-summary", () => {
index 6ffeba9ce9397108ba65a4b73657ebeb2478fae8..46af1c244e63d8228c28b96d0ad8763fc53d1fe9 100644 (file)
@@ -11,6 +11,7 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend"], function (requi
     exports.serviceWorkerSupported = serviceWorkerSupported;
     exports.setup = setup;
     exports.registerServiceWorker = registerServiceWorker;
+    exports.updateLastNotificationTime = updateLastNotificationTime;
     let _serviceWorker = null;
     class ServiceWorker {
         #publicKey;
@@ -87,6 +88,12 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend"], function (requi
             }
             return outputArray;
         }
+        updateLastNotificationTime(timestamp) {
+            window.navigator.serviceWorker.controller?.postMessage({
+                type: "SAVE_LAST_NOTIFICATION_TIMESTAMP",
+                timestamp: timestamp,
+            });
+        }
     }
     function serviceWorkerSupported() {
         if (location.protocol !== "https:") {
@@ -107,16 +114,20 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend"], function (requi
         }
         return true;
     }
-    function setup(publicKey, serviceWorkerJsUrl, registerUrl) {
+    function setup(publicKey, serviceWorkerJsUrl, registerUrl, lastReadNotification) {
         if (!serviceWorkerSupported()) {
             return;
         }
         _serviceWorker = new ServiceWorker(publicKey, serviceWorkerJsUrl, registerUrl);
         if (Notification.permission === "granted") {
             registerServiceWorker();
+            _serviceWorker.updateLastNotificationTime(lastReadNotification);
         }
     }
     function registerServiceWorker() {
         void _serviceWorker?.register();
     }
+    function updateLastNotificationTime(timestamp) {
+        _serviceWorker?.updateLastNotificationTime(timestamp);
+    }
 });
index 95b5fe5bbca79dadeec32ead14d454a531928fe5..f5b7fae90efd73b1eab6a0a7b7e5356abf193f2c 100644 (file)
@@ -1099,4 +1099,21 @@ class UserNotificationHandler extends SingletonFactory
 
         return \array_intersect($userIDs, $filterUserIDs);
     }
+
+    /**
+     * Returns the timestamp of the last read notification for the active user.
+     * Or `0` if no notification has been read yet.
+     *
+     * @return  int
+     */
+    public function getLastReadNotificationTime(): int
+    {
+        $sql = "SELECT MAX(confirmTime)
+                FROM   wcf1_user_notification
+                WHERE  userID = ?";
+        $statement = WCF::getDB()->prepare($sql);
+        $statement->execute([WCF::getUser()->userID]);
+
+        return $statement->fetchSingleColumn() ?: 0;
+    }
 }
index 16a7d05002a5e2dcb8b4ef1bd26d5c427202c903..8bfc6ecdbf3b5d9d5d24650fbd6a8bdc8a0e4e31 100644 (file)
@@ -19,22 +19,28 @@ self.addEventListener("push", (event) => {
 
        const payload = event.data.json();
 
-       event.waitUntil(
-               removeOldNotifications(payload.notificationID, payload.time).then(() =>
-                       self.registration.showNotification(payload.title, {
-                               body: payload.message,
-                               icon: payload.icon,
-                               timestamp: payload.time * 1000,
-                               tag: payload.notificationID,
-                               data: {
-                                       url: payload.url,
-                                       time: payload.time,
-                               },
-                       }),
-               ).then(() => {
-                       sendToClients(payload);
-               }),
-       );
+       getLastNotificationTimestamp().then(lastNotificationTimestamp => {
+    if (!lastNotificationTimestamp || payload.time < lastNotificationTimestamp) {
+      return;
+    }
+
+    event.waitUntil(
+      removeOldNotifications(payload.notificationID, payload.time).then(() =>
+        self.registration.showNotification(payload.title, {
+          body: payload.message,
+          icon: payload.icon,
+          timestamp: payload.time * 1000,
+          tag: payload.notificationID,
+          data: {
+            url: payload.url,
+            time: payload.time,
+          },
+        }),
+      ).then(() => {
+        sendToClients(payload);
+      }),
+    );
+  });
 });
 
 self.addEventListener("notificationclick", (event) => {
@@ -43,6 +49,12 @@ self.addEventListener("notificationclick", (event) => {
        event.waitUntil(self.clients.openWindow(event.notification.data.url));
 });
 
+self.addEventListener('message', event => {
+  if (event.data && event.data.type === 'SAVE_LAST_NOTIFICATION_TIMESTAMP') {
+    saveLastNotificationTimestamp(event.data.timestamp);
+  }
+});
+
 async function sendToClients(payload){
        const allClients = await self.clients.matchAll({
                includeUncontrolled: true,
@@ -70,3 +82,77 @@ async function removeOldNotifications(notificationID, time) {
                        notification.close();
                });
 }
+
+/**
+ * IndexedDB functions to store the last notification timestamp.
+ */
+function openDatabase() {
+  return new Promise((resolve, reject) => {
+    const request = indexedDB.open('WOLTLAB_SUITE_CORE', 1);
+
+    request.onupgradeneeded = (event) => {
+      const db = event.target.result;
+      if (!db.objectStoreNames.contains('notifications')) {
+        db.createObjectStore('notifications');
+      }
+    };
+
+    request.onsuccess = (event) => {
+      resolve(event.target.result);
+
+      if (!db.objectStoreNames.contains('notifications')) {
+        db.createObjectStore('notifications');
+      }
+    };
+
+    request.onerror = (event) => {
+      reject('Database error: ' + event.target.errorCode);
+    };
+  });
+}
+
+function saveLastNotificationTimestamp(timestamp) {
+  if (!timestamp || timestamp <= 0) {
+    return;
+  }
+
+  openDatabase().then(db => {
+    const tx = db.transaction('notifications', 'readwrite');
+    const store = tx.objectStore('notifications');
+    const getRequest = store.get('lastNotification');
+
+    getRequest.onsuccess = () => {
+      const storedTimestamp = getRequest.result;
+
+      // Check if the new timestamp is greater than the stored timestamp
+      if (storedTimestamp === undefined || newTimestamp > storedTimestamp) {
+        store.put(timestamp, 'lastNotification');
+      }
+    };
+
+    getRequest.onerror = () => {
+      console.error('Failed to retrieve timestamp', getRequest.error);
+    };
+
+    tx.onerror = () => {
+      console.error('Transaktionsfehler', tx.error);
+    };
+  }).catch(err => console.error('Failed to open database', err));
+}
+
+function getLastNotificationTimestamp() {
+  return openDatabase().then(db => {
+    return new Promise((resolve, reject) => {
+      const tx = db.transaction('notifications', 'readonly');
+      const store = tx.objectStore('notifications');
+      const request = store.get('lastNotification');
+
+      request.onsuccess = () => {
+        resolve(request.result);
+      };
+      request.onerror = () => {
+        reject('Failed to retrieve timestamp');
+      };
+    });
+  });
+}