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();