The site could only reach somebody who was already looking at it. Push existed and adventure used none of it, so the one communal event in the game -- the Siege -- was invisible to anyone not sitting in Matrix, and a player whose adventurer died found out whenever they next opened a tab. Four opt-in categories, every one of them off until asked for: the Siege (realm-wide, begins and ends), your expedition ending, your adventurer wandering off, and a contract landing on you. Turning on news notifications is not consent to be told about the game, so nothing here enrolls anybody automatically. No new wire. Every trigger is a dispatch already landing in adventure_events, so this is Pete-side only and gogobee is untouched. Two things it needed from storage. push_subscriptions now keeps the Matrix localpart alongside the OIDC subject, because every ownership join in the schema is keyed on the localpart and the sender runs on a ticker with no session to read one from -- without it there is no way to answer "whose adventurer is this". And the alerts carry their own watermark, kept apart from the digest's: the two run on different clocks and one column would let each consume the other's backlog. The ownership join is re-read on every pass rather than trusted from the subscription row, so an opt-out or a removal closes the channel at once. It fails closed in both directions, and an unresolved owner can never fall through to a broadcast -- a game alert naming somebody's adventurer, delivered to the wrong phone, is a privacy leak dressed as a feature. An existing subscription carries watermark 0, which read literally means "has never been told anything" and would page every subscriber for the whole history of the realm on the first tick after deploy. Those rows are stamped to now and start from the next dispatch. Verified against a running Pete with a real push service, real P-256 client keys and real encryption: the right person is notified, the wrong one is not, a second pass is silent, and dropping the player from the board takes the channel with it. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
180 lines
6.2 KiB
JavaScript
180 lines
6.2 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;
|
|
});
|
|
});
|
|
}
|
|
|
|
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.");
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
})();
|