Correctly handle standalone (PWA) mode changes
- 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.pull/781/head
parent
532fd3c560
commit
a8d3297c4e
|
@ -43,7 +43,7 @@ class Notifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async webPushSubscription() {
|
async webPushSubscription(hasWebPushTopics) {
|
||||||
if (!this.pushPossible()) {
|
if (!this.pushPossible()) {
|
||||||
throw new Error("Unsupported or denied");
|
throw new Error("Unsupported or denied");
|
||||||
}
|
}
|
||||||
|
@ -53,11 +53,11 @@ class Notifier {
|
||||||
return existingSubscription;
|
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.
|
// 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, 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({
|
return pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlB64ToUint8Array(config.web_push_public_key),
|
applicationServerKey: urlB64ToUint8Array(config.web_push_public_key),
|
||||||
|
@ -119,11 +119,6 @@ class Notifier {
|
||||||
return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();
|
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
|
* 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
|
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
import { isLaunchedPWA } from "./utils";
|
|
||||||
|
|
||||||
class Prefs {
|
class Prefs {
|
||||||
constructor(dbImpl) {
|
constructor(dbImpl) {
|
||||||
|
@ -35,7 +34,7 @@ class Prefs {
|
||||||
|
|
||||||
async webPushEnabled() {
|
async webPushEnabled() {
|
||||||
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
|
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
|
||||||
return webPushEnabled?.value ?? isLaunchedPWA();
|
return webPushEnabled?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setWebPushEnabled(enabled) {
|
async setWebPushEnabled(enabled) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import api from "./Api";
|
||||||
import notifier from "./Notifier";
|
import notifier from "./Notifier";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
import { topicUrl } from "./utils";
|
import { isLaunchedPWA, topicUrl } from "./utils";
|
||||||
|
|
||||||
class SubscriptionManager {
|
class SubscriptionManager {
|
||||||
constructor(dbImpl) {
|
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
|
* 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.
|
* will not react to it, and the Web Push topics will not be updated when the user mutes a topic.
|
||||||
*/
|
*/
|
||||||
async webPushTopics() {
|
async webPushTopics(isStandalone = isLaunchedPWA(), pushPossible = notifier.pushPossible()) {
|
||||||
// the Promise.resolve wrapper is not superfluous, without it the live query breaks:
|
if (!pushPossible) {
|
||||||
// https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier
|
|
||||||
const pushEnabled = await Promise.resolve(notifier.pushEnabled());
|
|
||||||
if (!pushEnabled) {
|
|
||||||
return [];
|
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();
|
const subscriptions = await this.db.subscriptions.where({ baseUrl: config.base_url, mutedUntil: 0 }).toArray();
|
||||||
return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);
|
return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);
|
||||||
}
|
}
|
||||||
|
@ -117,14 +121,17 @@ class SubscriptionManager {
|
||||||
|
|
||||||
async updateWebPushSubscriptions(presetTopics) {
|
async updateWebPushSubscriptions(presetTopics) {
|
||||||
const topics = presetTopics ?? (await this.webPushTopics());
|
const topics = presetTopics ?? (await this.webPushTopics());
|
||||||
const browserSubscription = await notifier.webPushSubscription();
|
|
||||||
|
const hasWebPushTopics = topics.length > 0;
|
||||||
|
|
||||||
|
const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics);
|
||||||
|
|
||||||
if (!browserSubscription) {
|
if (!browserSubscription) {
|
||||||
console.log("[SubscriptionManager] No browser subscription currently exists, so web push was never enabled. Skipping.");
|
console.log("[SubscriptionManager] No browser subscription currently exists, so web push was never enabled. Skipping.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topics.length > 0) {
|
if (hasWebPushTopics) {
|
||||||
await api.updateWebPush(browserSubscription, topics);
|
await api.updateWebPush(browserSubscription, topics);
|
||||||
} else {
|
} else {
|
||||||
await api.deleteWebPush(browserSubscription);
|
await api.deleteWebPush(browserSubscription);
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
|
||||||
import notifier from "./Notifier";
|
import notifier from "./Notifier";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
|
|
||||||
const intervalMillis = 13 * 60 * 1_000; // 13 minutes
|
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||||
const updateIntervalMillis = 60 * 60 * 1_000; // 1 hour
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 useWebPushListener = (topics) => {
|
||||||
const topics = useLiveQuery(() => subscriptionManager.webPushTopics());
|
|
||||||
const [lastTopics, setLastTopics] = useState();
|
const [lastTopics, setLastTopics] = useState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -29,63 +28,18 @@ export const useWebPushTopicListener = () => {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [topics, lastTopics]);
|
}, [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);
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export default useWebPushListener;
|
||||||
* 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();
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import userManager from "../app/UserManager";
|
||||||
import { expandUrl } from "../app/utils";
|
import { expandUrl } from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import { useAccountListener, useBackgroundProcesses, useConnectionListeners } from "./hooks";
|
import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
|
||||||
import PublishDialog from "./PublishDialog";
|
import PublishDialog from "./PublishDialog";
|
||||||
import Messaging from "./Messaging";
|
import Messaging from "./Messaging";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
|
@ -68,7 +68,7 @@ const Layout = () => {
|
||||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
const webPushTopics = useLiveQuery(() => subscriptionManager.webPushTopics());
|
const webPushTopics = useWebPushTopics();
|
||||||
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
|
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
|
||||||
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||||
const [selected] = (subscriptionsWithoutInternal || []).filter(
|
const [selected] = (subscriptionsWithoutInternal || []).filter(
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
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 routes from "./routes";
|
||||||
import connectionManager from "../app/ConnectionManager";
|
import connectionManager from "../app/ConnectionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
|
@ -9,7 +10,8 @@ import pruner from "../app/Pruner";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
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
|
* 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]);
|
}, [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
|
* 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
|
* 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();
|
poller.startWorker();
|
||||||
pruner.startWorker();
|
pruner.startWorker();
|
||||||
accountApi.startWorker();
|
accountApi.startWorker();
|
||||||
webPush.startWorker();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopWorkers = () => {
|
const stopWorkers = () => {
|
||||||
poller.stopWorker();
|
poller.stopWorker();
|
||||||
pruner.stopWorker();
|
pruner.stopWorker();
|
||||||
accountApi.stopWorker();
|
accountApi.stopWorker();
|
||||||
webPush.stopWorker();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useBackgroundProcesses = () => {
|
export const useBackgroundProcesses = () => {
|
||||||
useWebPushTopicListener();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[useBackgroundProcesses] mounting");
|
console.log("[useBackgroundProcesses] mounting");
|
||||||
startWorkers();
|
startWorkers();
|
||||||
|
|
Loading…
Reference in New Issue