Three verbs to match gogobee's: call off an expedition, turn back out of somebody else's party, send the pet sitter home. Which one this page offers is derived here rather than pushed — leadership is already legible in the party seats and the sitter's standing is already in the babysit offer, so nothing new crosses the wire. Two things running it turned up that no test would have. An applied abandon left "Pull out of the run" sitting under a verdict saying the expedition was over, so an applied verb now also hides the other verbs it just made untrue. And a party member was being offered that same button in the first place, beside the one that actually works — Pete knows from the seat it just read that gogobee would refuse it, so it is withheld. Also: heal the Matrix handle onto push rows stored before the column existed, on its own endpoint rather than through the subscribe upsert, which resets both watermarks and would have silenced the digest for anybody who reads the site regularly. And stack the board row below sm — four flex columns that wrapped to six lines on a phone, pre-existing.
208 lines
7.5 KiB
JavaScript
208 lines
7.5 KiB
JavaScript
// 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();
|
|
}
|
|
})();
|