From a8d3297c4ee7c08cff1c48b8cadb98b053ce72e1 Mon Sep 17 00:00:00 2001
From: nimbleghost <132819643+nimbleghost@users.noreply.github.com>
Date: Sun, 25 Jun 2023 21:25:30 +0200
Subject: [PATCH] Correctly handle standalone (PWA) mode changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Also handle notification permission changes
- Remove web push schedule worker since this complicates
  things and doesn’t do _that_ much. We have the reminder
  notification if the user truly doesn’t reload ntfy in
  more than a week.
---
 web/src/app/Notifier.js            | 11 ++--
 web/src/app/Prefs.js               |  3 +-
 web/src/app/SubscriptionManager.js | 23 ++++++---
 web/src/app/WebPush.js             | 82 +++++++-----------------------
 web/src/components/App.jsx         |  4 +-
 web/src/components/hooks.js        | 58 ++++++++++++++++++---
 6 files changed, 91 insertions(+), 90 deletions(-)

diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js
index fa1498a3..e4232175 100644
--- a/web/src/app/Notifier.js
+++ b/web/src/app/Notifier.js
@@ -43,7 +43,7 @@ class Notifier {
     }
   }
 
-  async webPushSubscription() {
+  async webPushSubscription(hasWebPushTopics) {
     if (!this.pushPossible()) {
       throw new Error("Unsupported or denied");
     }
@@ -53,11 +53,11 @@ class Notifier {
       return existingSubscription;
     }
 
-    // Create a new subscription only if Web Push is enabled. It is possible that Web Push
+    // Create a new subscription only if there are new topics to subscribe to. It is possible that Web Push
     // was previously enabled and then disabled again in which case there would be an existingSubscription.
     // If, however, it was _not_ enabled previously, we create a new subscription if it is now enabled.
 
-    if (await this.pushEnabled()) {
+    if (hasWebPushTopics) {
       return pushManager.subscribe({
         userVisibleOnly: true,
         applicationServerKey: urlB64ToUint8Array(config.web_push_public_key),
@@ -119,11 +119,6 @@ class Notifier {
     return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();
   }
 
-  async pushEnabled() {
-    const enabled = await prefs.webPushEnabled();
-    return this.pushPossible() && enabled;
-  }
-
   /**
    * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
    * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js
index 632c9adc..a9510dd2 100644
--- a/web/src/app/Prefs.js
+++ b/web/src/app/Prefs.js
@@ -1,5 +1,4 @@
 import db from "./db";
-import { isLaunchedPWA } from "./utils";
 
 class Prefs {
   constructor(dbImpl) {
@@ -35,7 +34,7 @@ class Prefs {
 
   async webPushEnabled() {
     const webPushEnabled = await this.db.prefs.get("webPushEnabled");
-    return webPushEnabled?.value ?? isLaunchedPWA();
+    return webPushEnabled?.value;
   }
 
   async setWebPushEnabled(enabled) {
diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js
index 4e2f400a..2d8e79cf 100644
--- a/web/src/app/SubscriptionManager.js
+++ b/web/src/app/SubscriptionManager.js
@@ -2,7 +2,7 @@ import api from "./Api";
 import notifier from "./Notifier";
 import prefs from "./Prefs";
 import db from "./db";
-import { topicUrl } from "./utils";
+import { isLaunchedPWA, topicUrl } from "./utils";
 
 class SubscriptionManager {
   constructor(dbImpl) {
@@ -27,13 +27,17 @@ class SubscriptionManager {
    * It is important to note that "mutedUntil" must be part of the where() query, otherwise the Dexie live query
    * will not react to it, and the Web Push topics will not be updated when the user mutes a topic.
    */
-  async webPushTopics() {
-    // the Promise.resolve wrapper is not superfluous, without it the live query breaks:
-    // https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier
-    const pushEnabled = await Promise.resolve(notifier.pushEnabled());
-    if (!pushEnabled) {
+  async webPushTopics(isStandalone = isLaunchedPWA(), pushPossible = notifier.pushPossible()) {
+    if (!pushPossible) {
       return [];
     }
+
+    // the Promise.resolve wrapper is not superfluous, without it the live query breaks:
+    // https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier
+    if (!(isStandalone || (await Promise.resolve(prefs.webPushEnabled())))) {
+      return [];
+    }
+
     const subscriptions = await this.db.subscriptions.where({ baseUrl: config.base_url, mutedUntil: 0 }).toArray();
     return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);
   }
@@ -117,14 +121,17 @@ class SubscriptionManager {
 
   async updateWebPushSubscriptions(presetTopics) {
     const topics = presetTopics ?? (await this.webPushTopics());
-    const browserSubscription = await notifier.webPushSubscription();
+
+    const hasWebPushTopics = topics.length > 0;
+
+    const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics);
 
     if (!browserSubscription) {
       console.log("[SubscriptionManager] No browser subscription currently exists, so web push was never enabled. Skipping.");
       return;
     }
 
-    if (topics.length > 0) {
+    if (hasWebPushTopics) {
       await api.updateWebPush(browserSubscription, topics);
     } else {
       await api.deleteWebPush(browserSubscription);
diff --git a/web/src/app/WebPush.js b/web/src/app/WebPush.js
index efd06816..1e979239 100644
--- a/web/src/app/WebPush.js
+++ b/web/src/app/WebPush.js
@@ -1,16 +1,15 @@
 import { useState, useEffect } from "react";
-import { useLiveQuery } from "dexie-react-hooks";
 import notifier from "./Notifier";
 import subscriptionManager from "./SubscriptionManager";
 
-const intervalMillis = 13 * 60 * 1_000; // 13 minutes
-const updateIntervalMillis = 60 * 60 * 1_000; // 1 hour
+const broadcastChannel = new BroadcastChannel("web-push-broadcast");
 
 /**
- * Updates the Web Push subscriptions when the list of topics changes.
+ * Updates the Web Push subscriptions when the list of topics changes,
+ * as well as plays a sound when a new broadcat message is received from
+ * the service worker, since the service worker cannot play sounds.
  */
-export const useWebPushTopicListener = () => {
-  const topics = useLiveQuery(() => subscriptionManager.webPushTopics());
+const useWebPushListener = (topics) => {
   const [lastTopics, setLastTopics] = useState();
 
   useEffect(() => {
@@ -29,63 +28,18 @@ export const useWebPushTopicListener = () => {
       }
     })();
   }, [topics, lastTopics]);
+
+  useEffect(() => {
+    const onMessage = () => {
+      notifier.playSound(); // Service Worker cannot play sound, so we do it here!
+    };
+
+    broadcastChannel.addEventListener("message", onMessage);
+
+    return () => {
+      broadcastChannel.removeEventListener("message", onMessage);
+    };
+  });
 };
 
-/**
- * Helper class for Web Push that does three things:
- * 1. Updates the Web Push subscriptions on a schedule
- * 2. Updates the Web Push subscriptions when the window is minimised / app switched
- * 3. Listens to the broadcast channel from the service worker to play a sound when a message comes in
- */
-class WebPushWorker {
-  constructor() {
-    this.timer = null;
-    this.lastUpdate = null;
-    this.messageHandler = this.onMessage.bind(this);
-    this.visibilityHandler = this.onVisibilityChange.bind(this);
-  }
-
-  startWorker() {
-    if (this.timer !== null) {
-      return;
-    }
-
-    this.timer = setInterval(() => this.updateSubscriptions(), intervalMillis);
-    this.broadcastChannel = new BroadcastChannel("web-push-broadcast");
-    this.broadcastChannel.addEventListener("message", this.messageHandler);
-
-    document.addEventListener("visibilitychange", this.visibilityHandler);
-  }
-
-  stopWorker() {
-    clearTimeout(this.timer);
-
-    this.broadcastChannel.removeEventListener("message", this.messageHandler);
-    this.broadcastChannel.close();
-
-    document.removeEventListener("visibilitychange", this.visibilityHandler);
-  }
-
-  onMessage() {
-    notifier.playSound(); // Service Worker cannot play sound, so we do it here!
-  }
-
-  onVisibilityChange() {
-    if (document.visibilityState === "visible") {
-      this.updateSubscriptions();
-    }
-  }
-
-  async updateSubscriptions() {
-    if (!notifier.pushPossible()) {
-      return;
-    }
-
-    if (!this.lastUpdate || Date.now() - this.lastUpdate > updateIntervalMillis) {
-      await subscriptionManager.updateWebPushSubscriptions();
-      this.lastUpdate = Date.now();
-    }
-  }
-}
-
-export const webPush = new WebPushWorker();
+export default useWebPushListener;
diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx
index 4854fc85..7fdc706e 100644
--- a/web/src/components/App.jsx
+++ b/web/src/components/App.jsx
@@ -15,7 +15,7 @@ import userManager from "../app/UserManager";
 import { expandUrl } from "../app/utils";
 import ErrorBoundary from "./ErrorBoundary";
 import routes from "./routes";
-import { useAccountListener, useBackgroundProcesses, useConnectionListeners } from "./hooks";
+import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
 import PublishDialog from "./PublishDialog";
 import Messaging from "./Messaging";
 import Login from "./Login";
@@ -68,7 +68,7 @@ const Layout = () => {
   const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
   const users = useLiveQuery(() => userManager.all());
   const subscriptions = useLiveQuery(() => subscriptionManager.all());
-  const webPushTopics = useLiveQuery(() => subscriptionManager.webPushTopics());
+  const webPushTopics = useWebPushTopics();
   const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
   const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
   const [selected] = (subscriptionsWithoutInternal || []).filter(
diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js
index 65360ba0..5e9b2ed6 100644
--- a/web/src/components/hooks.js
+++ b/web/src/components/hooks.js
@@ -1,7 +1,8 @@
 import { useParams } from "react-router-dom";
 import { useEffect, useMemo, useState } from "react";
+import { useLiveQuery } from "dexie-react-hooks";
 import subscriptionManager from "../app/SubscriptionManager";
-import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
+import { disallowedTopic, expandSecureUrl, isLaunchedPWA, topicUrl } from "../app/utils";
 import routes from "./routes";
 import connectionManager from "../app/ConnectionManager";
 import poller from "../app/Poller";
@@ -9,7 +10,8 @@ import pruner from "../app/Pruner";
 import session from "../app/Session";
 import accountApi from "../app/AccountApi";
 import { UnauthorizedError } from "../app/errors";
-import { webPush, useWebPushTopicListener } from "../app/WebPush";
+import useWebPushListener from "../app/WebPush";
+import notifier from "../app/Notifier";
 
 /**
  * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -133,6 +135,54 @@ export const useAutoSubscribe = (subscriptions, selected) => {
   }, [params, subscriptions, selected, hasRun]);
 };
 
+export const useWebPushTopics = () => {
+  const matchMedia = window.matchMedia("(display-mode: standalone)");
+
+  const [isStandalone, setIsStandalone] = useState(isLaunchedPWA());
+  const [pushPossible, setPushPossible] = useState(notifier.pushPossible());
+
+  useEffect(() => {
+    const handler = (evt) => {
+      console.log(`[useWebPushTopics] App is now running ${evt.matches ? "standalone" : "in the browser"}`);
+      setIsStandalone(evt.matches);
+    };
+
+    matchMedia.addEventListener("change", handler);
+
+    return () => {
+      matchMedia.removeEventListener("change", handler);
+    };
+  });
+
+  useEffect(() => {
+    const handler = () => {
+      const newPushPossible = notifier.pushPossible();
+      console.log(`[useWebPushTopics] Notification Permission changed`, { pushPossible: newPushPossible });
+      setPushPossible(newPushPossible);
+    };
+
+    if ("permissions" in navigator) {
+      navigator.permissions.query({ name: "notifications" }).then((permission) => {
+        permission.addEventListener("change", handler);
+
+        return () => {
+          permission.removeEventListener("change", handler);
+        };
+      });
+    }
+  });
+
+  const topics = useLiveQuery(
+    async () => subscriptionManager.webPushTopics(isStandalone, pushPossible),
+    // invalidate (reload) query when these values change
+    [isStandalone, pushPossible]
+  );
+
+  useWebPushListener(topics);
+
+  return topics;
+};
+
 /**
  * Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
  * and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
@@ -143,19 +193,15 @@ const startWorkers = () => {
   poller.startWorker();
   pruner.startWorker();
   accountApi.startWorker();
-  webPush.startWorker();
 };
 
 const stopWorkers = () => {
   poller.stopWorker();
   pruner.stopWorker();
   accountApi.stopWorker();
-  webPush.stopWorker();
 };
 
 export const useBackgroundProcesses = () => {
-  useWebPushTopicListener();
-
   useEffect(() => {
     console.log("[useBackgroundProcesses] mounting");
     startWorkers();