// PWA glue: register the service worker, and (for signed-in users on a // push-enabled server) drive the notification opt-in toggle in the settings // panel. Anonymous visitors still get the offline reader — only the push // controls are gated behind sign-in + a configured VAPID key. (function () { if (!("serviceWorker" in navigator)) return; var CFG = window.PETE_PUSH || null; // { enabled, publicKey } or null var reg = null; navigator.serviceWorker.register("/sw.js").then(function (r) { reg = r; if (canPush()) initPushUI(); }).catch(function () {}); function canPush() { return !!(CFG && CFG.enabled && CFG.publicKey && window.PETE_USER && "PushManager" in window && "Notification" in window); } // ---- push subscription ---------------------------------------------------- function urlBase64ToUint8Array(base64String) { var padding = "=".repeat((4 - (base64String.length % 4)) % 4); var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); var raw = atob(base64); var out = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } function currentSub() { if (!reg) return Promise.resolve(null); return reg.pushManager.getSubscription(); } function subscribe() { return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(CFG.publicKey), }).then(function (sub) { return fetch("/api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sub.toJSON()), credentials: "same-origin", }).then(function (res) { if (!res.ok) throw new Error("subscribe rejected"); return sub; }); }); } // A subscription stored before the server learned to record the Matrix handle // can never match an owner-scoped adventure alert, and nothing re-subscribes on // its own — subscribe() only runs on a click. So an existing subscription gets // its handle topped up once, silently, from the page it is already on. // // Once per endpoint, not once per load: the marker is the endpoint itself, so a // rotated subscription heals again and a browser that has already done it never // asks twice. The server's update is a no-op on an already-healed row, so a lost // marker costs one wasted request and nothing else. var HEAL_KEY = "pete.pushHeal.v1"; function healLocalpart(sub) { if (!sub || !sub.endpoint) return; try { if (localStorage.getItem(HEAL_KEY) === sub.endpoint) return; } catch (e) { /* private mode: heal every load rather than never */ } fetch("/api/push/heal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: sub.endpoint }), credentials: "same-origin", }).then(function (res) { if (!res.ok) return; try { localStorage.setItem(HEAL_KEY, sub.endpoint); } catch (e) {} }).catch(function () { /* transient — the next load will do */ }); } function unsubscribe() { return currentSub().then(function (sub) { if (!sub) return; var endpoint = sub.endpoint; return sub.unsubscribe().then(function () { return fetch("/api/push/unsubscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: endpoint }), credentials: "same-origin", }).catch(function () {}); }); }); } // ---- adventure alert categories ------------------------------------------- // Stored as a JSON string under a synced prefs key, because this is the one // preference the *server* reads back: the alert sender parses the same blob to // decide who to notify. Absent key means nothing enabled, on both sides. var ADV_KEY = "pete.advPush.v1"; function advRead() { try { var raw = localStorage.getItem(ADV_KEY); if (!raw) return {}; var v = JSON.parse(raw); return v && typeof v === "object" ? v : {}; } catch (e) { return {}; } } function advWrite(set) { try { localStorage.setItem(ADV_KEY, JSON.stringify(set)); } catch (e) {} if (window.PetePrefs) window.PetePrefs.push(); } // initAdvUI wires the category boxes. `on` is whether a push subscription // currently exists — a category switch with no subscription behind it is wired // to nothing, so the block stays hidden until there is one. function initAdvUI(slot, on) { var box = slot.querySelector("[data-adv-push]"); if (!box) return; box.hidden = !on; if (!on) return; var note = box.querySelector("[data-adv-push-note]"); var inputs = box.querySelectorAll("[data-adv-cat]"); var set = advRead(); function paintNote() { if (!note) return; var n = 0; for (var i = 0; i < inputs.length; i++) if (inputs[i].checked) n++; note.textContent = n === 0 ? "Nothing selected. You'll only get the news digest." : (n === 1 ? "1 alert type on." : n + " alert types on."); } for (var i = 0; i < inputs.length; i++) { (function (el) { el.checked = !!set[el.getAttribute("data-adv-cat")]; // This function re-runs every time the subscription state changes; bind // the listener once or a toggle-off-toggle-on writes the pref twice. if (!el.dataset.advBound) { el.dataset.advBound = "1"; el.addEventListener("change", function () { var cur = advRead(); var key = el.getAttribute("data-adv-cat"); if (el.checked) cur[key] = true; else delete cur[key]; advWrite(cur); paintNote(); }); } })(inputs[i]); } paintNote(); } // ---- settings-panel toggle ------------------------------------------------ function initPushUI() { var slot = document.querySelector("[data-push-section]"); if (!slot) return; slot.hidden = false; var btn = slot.querySelector("[data-push-toggle]"); var note = slot.querySelector("[data-push-note]"); if (!btn) return; var busy = false; function paint(on, text) { btn.setAttribute("aria-pressed", on ? "true" : "false"); btn.textContent = on ? "Notifications on" : "Turn on notifications"; if (note && text != null) note.textContent = text; initAdvUI(slot, on); } function refresh() { if (Notification.permission === "denied") { btn.disabled = true; paint(false, "Notifications are blocked in your browser settings."); return; } currentSub().then(function (sub) { paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land."); healLocalpart(sub); }); } btn.addEventListener("click", function () { if (busy) return; busy = true; btn.disabled = true; currentSub().then(function (sub) { if (sub) return unsubscribe().then(function () { paint(false, "Notifications off."); }); return Notification.requestPermission().then(function (perm) { if (perm !== "granted") { paint(false, "Permission denied."); return; } return subscribe().then(function () { paint(true, "You're all set. New stories will nudge you."); }); }); }).catch(function () { paint(false, "Something went wrong. Try again."); }).finally(function () { busy = false; btn.disabled = Notification.permission === "denied"; }); }); refresh(); } })();