Add PWA + Web Push delivery for weather alerts (#95)
Make the app installable and deliver existing alert notifications as OS push, alongside the in-app bell. Backend: - PushSubscription model (per-device endpoint + keys, owned by a user) and register/unregister/test endpoints under /api/v2/push, cookie-auth scoped to the user like the subscription routes. - push.py: VAPID key management (env -> data/vapid.json -> generated) and a pywebpush send helper that reports gone endpoints for pruning. No DB coupling. - notify.py: after creating an in-app Notification, dispatch Web Push to the user's devices (guarded — a push failure never affects the in-app write; endpoints reported gone are pruned). - Serve the .webmanifest with the correct media type. Frontend: - manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages. - sw.js: push + notificationclick handlers (push-only; no fetch caching, so it doesn't fight the existing IndexedDB cache). Registered globally in nav.js in secure contexts. - push-client.js + a "Notifications on this device" toggle and test-send on the /alerts page, subscribing through the existing cookie-aware apiFetch. Push and service workers require a secure context, so this is active over HTTPS (or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
This commit is contained in:
parent
7567822522
commit
f34ab7ef96
14 changed files with 264 additions and 0 deletions
|
|
@ -21,6 +21,7 @@
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
BIN
static/logo-192.png
Normal file
BIN
static/logo-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
static/logo-maskable-512.png
Normal file
BIN
static/logo-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
16
static/manifest.webmanifest
Normal file
16
static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "Thermograph — how unusual is your weather?",
|
||||||
|
"short_name": "Thermograph",
|
||||||
|
"description": "See how recent weather anywhere on Earth stacks up against ~45 years of local climate history, and get alerts when it turns unusual.",
|
||||||
|
"start_url": ".",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#171b21",
|
||||||
|
"theme_color": "#f0803c",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "logo.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||||
|
{ "src": "logo-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "logo.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "logo-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -55,3 +55,13 @@ function refreshNav(lat, lon, date) {
|
||||||
const loc = loadLastLocation();
|
const loc = loadLastLocation();
|
||||||
if (loc) refreshNav(loc.lat, loc.lon, loc.date);
|
if (loc) refreshNav(loc.lat, loc.lon, loc.date);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Register the service worker so the app is installable and can receive Web Push
|
||||||
|
// (see sw.js). Base-relative, so it resolves under /thermograph automatically and
|
||||||
|
// takes /thermograph/ as its scope. A no-op in insecure contexts (plain-HTTP LAN)
|
||||||
|
// — registration just rejects and we swallow it; in-app features are unaffected.
|
||||||
|
if ("serviceWorker" in navigator && window.isSecureContext) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("sw.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
93
static/push-client.js
Normal file
93
static/push-client.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// Web Push enablement for the current device. The service worker itself is
|
||||||
|
// registered globally by nav.js; this module owns the *subscription* handshake:
|
||||||
|
// ask permission, subscribe with the server's VAPID key, and register the
|
||||||
|
// resulting endpoint against the signed-in user (via account.js's cookie-aware
|
||||||
|
// apiFetch). Used by the /alerts page to render a "Notifications on this device"
|
||||||
|
// control.
|
||||||
|
//
|
||||||
|
// Push + service workers require a secure context (HTTPS, or http://localhost),
|
||||||
|
// so on a plain-HTTP LAN origin this cleanly reports "unsupported" instead of
|
||||||
|
// throwing.
|
||||||
|
import { apiFetch } from "./account.js";
|
||||||
|
|
||||||
|
export function supported() {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.isSecureContext &&
|
||||||
|
"serviceWorker" in navigator &&
|
||||||
|
"PushManager" in window &&
|
||||||
|
"Notification" in window
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The applicationServerKey must be a Uint8Array of the base64url-decoded VAPID
|
||||||
|
// public key.
|
||||||
|
function urlB64ToUint8Array(base64) {
|
||||||
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(b64);
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registration() {
|
||||||
|
// nav.js registered sw.js already; ready resolves once it's active.
|
||||||
|
return navigator.serviceWorker.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this device currently subscribed? (a PushSubscription exists locally)
|
||||||
|
export async function isEnabled() {
|
||||||
|
if (!supported()) return false;
|
||||||
|
try {
|
||||||
|
const reg = await registration();
|
||||||
|
return !!(await reg.pushManager.getSubscription());
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function permission() {
|
||||||
|
return supported() ? Notification.permission : "unsupported";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe this device and register it server-side. Throws on failure so the
|
||||||
|
// caller can surface a message.
|
||||||
|
export async function enable() {
|
||||||
|
if (!supported()) throw new Error("This browser doesn't support push notifications.");
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
if (perm !== "granted") throw new Error("Notification permission was not granted.");
|
||||||
|
|
||||||
|
const reg = await registration();
|
||||||
|
let sub = await reg.pushManager.getSubscription();
|
||||||
|
if (!sub) {
|
||||||
|
const res = await apiFetch("api/v2/push/vapid-key");
|
||||||
|
if (!res.ok) throw new Error("Couldn't fetch the server key.");
|
||||||
|
const { key } = await res.json();
|
||||||
|
sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlB64ToUint8Array(key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await apiFetch("api/v2/push/subscribe", { method: "POST", json: sub.toJSON() });
|
||||||
|
if (!res.ok) throw new Error("Couldn't register this device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe locally and remove the endpoint server-side.
|
||||||
|
export async function disable() {
|
||||||
|
if (!supported()) return;
|
||||||
|
const reg = await registration();
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
if (!sub) return;
|
||||||
|
try {
|
||||||
|
await apiFetch("api/v2/push/subscribe", { method: "DELETE", json: { endpoint: sub.endpoint } });
|
||||||
|
} catch (e) { /* best-effort server cleanup */ }
|
||||||
|
await sub.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the server to push a test notification to every device on the account.
|
||||||
|
export async function sendTest() {
|
||||||
|
const res = await apiFetch("api/v2/push/test", { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error("Couldn't send a test notification.");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
@ -382,6 +382,24 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
||||||
.alerts-toolbar .find-btn { padding: 11px 18px; min-height: 44px; }
|
.alerts-toolbar .find-btn { padding: 11px 18px; min-height: 44px; }
|
||||||
.alerts-note { margin: 0; font-size: 13px; }
|
.alerts-note { margin: 0; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Per-device push toggle, above the account-wide alert list. */
|
||||||
|
.push-bar {
|
||||||
|
border: 1px solid var(--border); background: var(--surface); border-radius: 14px;
|
||||||
|
padding: 12px 16px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.push-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.push-status { font-size: 14px; font-weight: 600; }
|
||||||
|
.push-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.push-toggle { padding: 9px 16px; min-height: 44px; }
|
||||||
|
.push-test-btn {
|
||||||
|
min-height: 44px; padding: 9px 14px; border-radius: 10px; cursor: pointer;
|
||||||
|
border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 14px;
|
||||||
|
}
|
||||||
|
.push-test-btn:hover { border-color: var(--accent); }
|
||||||
|
.push-note { margin: 0; }
|
||||||
|
|
||||||
.sub-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; }
|
.sub-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; }
|
||||||
@media (min-width: 720px) { .sub-list { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } }
|
@media (min-width: 720px) { .sub-list { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } }
|
||||||
.sub-empty { padding: 20px; border: 1px dashed var(--border); border-radius: 12px; text-align: center; }
|
.sub-empty { padding: 20px; border: 1px dashed var(--border); border-radius: 12px; text-align: center; }
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import "./nav.js";
|
import "./nav.js";
|
||||||
import { apiFetch, openAuth, onAuthChange } from "./account.js";
|
import { apiFetch, openAuth, onAuthChange } from "./account.js";
|
||||||
import { open as openPicker } from "./mappicker.js";
|
import { open as openPicker } from "./mappicker.js";
|
||||||
|
import * as pushClient from "./push-client.js";
|
||||||
|
|
||||||
// Metric keys a user can watch, with friendly labels. (fmax/fmin exist server-side
|
// Metric keys a user can watch, with friendly labels. (fmax/fmin exist server-side
|
||||||
// but are omitted from the picker to keep it focused; labelled here if present.)
|
// but are omitted from the picker to keep it focused; labelled here if present.)
|
||||||
|
|
@ -64,12 +65,14 @@ function renderGate() {
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
|
<div class="push-bar" id="push-bar" hidden></div>
|
||||||
<div class="alerts-toolbar">
|
<div class="alerts-toolbar">
|
||||||
<button type="button" class="find-btn" id="add-alert">+ Add a city</button>
|
<button type="button" class="find-btn" id="add-alert">+ Add a city</button>
|
||||||
<p class="alerts-note muted">Each alert sends at most one notification per week.</p>
|
<p class="alerts-note muted">Each alert sends at most one notification per week.</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sub-list" id="sub-list"></ul>`;
|
<ul class="sub-list" id="sub-list"></ul>`;
|
||||||
body.querySelector("#add-alert").onclick = startAdd;
|
body.querySelector("#add-alert").onclick = startAdd;
|
||||||
|
renderPushBar();
|
||||||
const list = body.querySelector("#sub-list");
|
const list = body.querySelector("#sub-list");
|
||||||
if (!subs.length) {
|
if (!subs.length) {
|
||||||
list.innerHTML = `<li class="sub-empty muted">No alerts yet. Add a city to get started.</li>`;
|
list.innerHTML = `<li class="sub-empty muted">No alerts yet. Add a city to get started.</li>`;
|
||||||
|
|
@ -78,6 +81,70 @@ function render() {
|
||||||
subs.forEach((s) => list.appendChild(subCard(s)));
|
subs.forEach((s) => list.appendChild(subCard(s)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- push notifications toggle (this device) ---------------------------------
|
||||||
|
// Delivery over OS push is per-device: a subscription lives in each browser, so
|
||||||
|
// this control reflects/toggles *this* device, separate from the account-wide
|
||||||
|
// alert list below.
|
||||||
|
async function renderPushBar() {
|
||||||
|
const el = document.getElementById("push-bar");
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!pushClient.supported()) {
|
||||||
|
// Insecure context (plain-HTTP LAN) or an old browser — push simply isn't
|
||||||
|
// available. Keep it quiet: a single muted line, no dead controls.
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `<p class="push-note muted">Open this page over HTTPS (or on localhost) to get
|
||||||
|
OS notifications on this device. In-app alerts via the bell still work anywhere.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pushClient.permission() === "denied") {
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `<p class="push-note muted">Notifications are blocked for this site — re-enable
|
||||||
|
them in your browser settings to get OS alerts here.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = await pushClient.isEnabled();
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="push-row">
|
||||||
|
<span class="push-status">${enabled
|
||||||
|
? "🔔 OS notifications are on for this device."
|
||||||
|
: "🔕 OS notifications are off for this device."}</span>
|
||||||
|
<span class="push-actions">
|
||||||
|
<button type="button" class="find-btn push-toggle">${enabled ? "Turn off" : "Turn on"}</button>
|
||||||
|
${enabled ? `<button type="button" class="push-test-btn" title="Send a test notification">Send test</button>` : ""}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
el.querySelector(".push-toggle").onclick = async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (enabled) await pushClient.disable();
|
||||||
|
else await pushClient.enable();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || "Couldn't change notification settings.");
|
||||||
|
}
|
||||||
|
renderPushBar(); // repaint from the new state
|
||||||
|
};
|
||||||
|
const testBtn = el.querySelector(".push-test-btn");
|
||||||
|
if (testBtn) {
|
||||||
|
testBtn.onclick = async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.textContent;
|
||||||
|
try {
|
||||||
|
await pushClient.sendTest();
|
||||||
|
btn.textContent = "Sent ✓";
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || "Couldn't send a test notification.");
|
||||||
|
}
|
||||||
|
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2500);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- a single subscription card ----------------------------------------------
|
// --- a single subscription card ----------------------------------------------
|
||||||
function subCard(s) {
|
function subCard(s) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
|
|
|
||||||
54
static/sw.js
Normal file
54
static/sw.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Thermograph service worker — push delivery only.
|
||||||
|
//
|
||||||
|
// Deliberately NO fetch/offline caching: the app already has an app-level
|
||||||
|
// IndexedDB response cache (cache.js), and a caching service worker would fight
|
||||||
|
// it. This worker exists purely so the app is installable (a registered SW is a
|
||||||
|
// PWA prerequisite) and can receive Web Push while no tab is open.
|
||||||
|
//
|
||||||
|
// Served from {BASE}/sw.js, so its scope is {BASE}/ — it controls the whole app.
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting(); // activate this version immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim()); // take control of open pages at once
|
||||||
|
});
|
||||||
|
|
||||||
|
// A push arrived. The payload is the JSON we send from push.py:
|
||||||
|
// { title, body, url, tag }.
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = event.data ? event.data.json() : {};
|
||||||
|
} catch (e) {
|
||||||
|
data = { title: "Thermograph", body: event.data ? event.data.text() : "" };
|
||||||
|
}
|
||||||
|
const title = data.title || "Thermograph";
|
||||||
|
const options = {
|
||||||
|
body: data.body || "",
|
||||||
|
icon: "logo-192.png",
|
||||||
|
badge: "logo-192.png",
|
||||||
|
tag: data.tag || "thermograph",
|
||||||
|
data: { url: data.url || "./" },
|
||||||
|
};
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tapping a notification: focus an existing app tab if one is open, else open the
|
||||||
|
// deep link the notification carried.
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const target = (event.notification.data && event.notification.data.url) || "./";
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if ("focus" in client) {
|
||||||
|
client.navigate(target).catch(() => {});
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(target);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue