Staff push notifications, and revalidate embedded assets
Notifications: staff turn them on per device from a new Notifications page, pick each type (bans, gags, mutes and silences added or lifted, server down and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game penalties are sent too. Web Push with VAPID and RFC 8291 encryption in internal/push, standard library only. Tables in sql/push.sql, created at startup; the VAPID key is made on first start and kept in saw_settings. Penalty notifications link to the public list searching for the player (#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3). Caching: pages, scripts and styles are served with Cache-Control: no-cache and an ETag, so a deploy shows up at once instead of after Cloudflare's default 4-hour browser cache.
This commit is contained in:
parent
7900bf5b3e
commit
17232bd5a2
23 changed files with 2135 additions and 12 deletions
|
|
@ -10,6 +10,7 @@ const PAGES = [
|
|||
// Only when GroupVerify's tables are in the database.
|
||||
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
|
||||
{ id: "settings", label: "Server settings" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
];
|
||||
|
||||
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
|
||||
|
|
@ -517,6 +518,136 @@ function overrideDialog(sid, name) {
|
|||
</form>`);
|
||||
}
|
||||
|
||||
/* ---------- Notifications ---------- */
|
||||
|
||||
// Push notifications to this staff member's devices (internal/web/notify.go, sw.js). Preferences
|
||||
// apply to all their devices and save as soon as they change.
|
||||
|
||||
const b64urlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
||||
const bytesToB64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const browserTimezone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
|
||||
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
|
||||
async function sha256Hex(text) {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// This browser's push subscription, or null.
|
||||
async function pushSubscription() {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg ? reg.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
async function notifications() {
|
||||
const v = $("#view");
|
||||
let d;
|
||||
try { d = await api("/api/admin/notifications"); } catch (err) { v.innerHTML = errorBox(err); return; }
|
||||
let here = false;
|
||||
if (d.available && pushSupported() && window.crypto?.subtle) {
|
||||
try {
|
||||
const sub = await pushSubscription();
|
||||
here = !!sub && d.devices.includes(await sha256Hex(sub.endpoint));
|
||||
} catch { /* treat as off */ }
|
||||
}
|
||||
if (state.page !== "notifications") return;
|
||||
state.notif = d;
|
||||
const p = d.prefs;
|
||||
const others = d.devices.length - (here ? 1 : 0);
|
||||
const row = (label, help, control) => `<div class="setting">
|
||||
<div><b>${label}</b>${help ? `<p>${help}</p>` : ""}</div>
|
||||
<div class="control">${control}</div>
|
||||
</div>`;
|
||||
|
||||
let device;
|
||||
if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", '<span class="tag red">Unavailable</span>');
|
||||
else if (!pushSupported()) device = row("This device", "This browser can't get notifications. On an iPhone or iPad, add the site to your home screen and open it from there.", '<span class="tag red">Not supported</span>');
|
||||
else if (Notification.permission === "denied") device = row("This device", "Notifications are blocked for this site. Allow them in the browser's site settings (or the app's notification settings), then come back.", '<span class="tag red">Blocked</span>');
|
||||
else if (here) device = row("This device", "Notifications are on here.", '<div style="display:flex;gap:8px"><button class="btn" type="button" data-act="notif-test">Send a test</button><button class="btn" type="button" data-act="notif-off">Turn off</button></div>');
|
||||
else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", '<button class="btn primary" type="button" data-act="notif-on">Turn on</button>');
|
||||
|
||||
const dis = d.available ? "" : "disabled";
|
||||
const groups = [...new Set(d.types.map((t) => t.group))];
|
||||
v.innerHTML = `
|
||||
<div class="page-head">
|
||||
<div><h1>Notifications</h1><p class="sub">Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.</p></div>
|
||||
</div>
|
||||
<div class="settings">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><h2>Devices</h2></div></div>
|
||||
${device}
|
||||
${others > 0 ? row(`${here ? "Other devices" : "Your devices"}`, `${others} ${others === 1 ? "device has" : "devices have"} notifications on.`, '<button class="btn" type="button" data-act="notif-off-all">Turn off everywhere</button>') : ""}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><h2>Quiet hours</h2><p>Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).</p></div></div>
|
||||
${row("Quiet hours", "", `<button class="switch" type="button" role="switch" aria-checked="${p.quiet.enabled}" aria-label="Quiet hours" data-notif="quiet" ${dis}></button>`)}
|
||||
${row("From and until", "", `<div class="quiet-times"><input class="input" type="time" id="quiet-start" value="${esc(p.quiet.start)}" aria-label="Quiet from" data-notif-time ${dis}><span class="muted">to</span><input class="input" type="time" id="quiet-end" value="${esc(p.quiet.end)}" aria-label="Quiet until" data-notif-time ${dis}></div>`)}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><h2>What to send</h2><p>For all your devices.</p></div></div>
|
||||
${groups.map((g) => `<div class="notif-group">${esc(g)}</div>
|
||||
${d.types.filter((t) => t.group === g).map((t) => row(esc(t.label), t.help ? esc(t.help) : "",
|
||||
t.ready
|
||||
? `<button class="switch" type="button" role="switch" aria-checked="${p.types.includes(t.key)}" aria-label="${esc(t.label)}" data-ntype="${esc(t.key)}" ${dis}></button>`
|
||||
: `<span class="tag">Coming later</span><button class="switch" type="button" role="switch" aria-checked="false" aria-label="${esc(t.label)}" disabled></button>`)).join("")}`).join("")}
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let notifSave;
|
||||
function saveNotifPrefs() {
|
||||
const types = [...document.querySelectorAll("[data-ntype]")].filter((s) => s.getAttribute("aria-checked") === "true").map((s) => s.dataset.ntype);
|
||||
const body = {
|
||||
types,
|
||||
quiet: { enabled: $("[data-notif=quiet]").getAttribute("aria-checked") === "true", start: $("#quiet-start").value, end: $("#quiet-end").value },
|
||||
timezone: browserTimezone(),
|
||||
};
|
||||
// Several quick toggles make one save.
|
||||
clearTimeout(notifSave);
|
||||
notifSave = setTimeout(async () => {
|
||||
try { await api("/api/admin/notifications/prefs", { method: "PUT", body }); toast("Saved."); } catch (err) { toast(err.message); }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
const d = state.notif;
|
||||
try {
|
||||
if (await Notification.requestPermission() !== "granted") {
|
||||
toast("Notifications weren't allowed.");
|
||||
notifications();
|
||||
return;
|
||||
}
|
||||
if (!(await navigator.serviceWorker.getRegistration())) await navigator.serviceWorker.register("/sw.js");
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
// A subscription made for another panel key can't be reused.
|
||||
if (sub && sub.options?.applicationServerKey && bytesToB64url(sub.options.applicationServerKey) !== d.publicKey) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64urlToBytes(d.publicKey) });
|
||||
await act("/api/admin/notifications/subscribe", "POST", { ...sub.toJSON(), userAgent: navigator.userAgent, timezone: browserTimezone() });
|
||||
} catch (err) {
|
||||
toast(`Couldn't turn notifications on: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableNotifications(all) {
|
||||
let endpoint = "";
|
||||
try {
|
||||
const sub = await pushSubscription();
|
||||
if (sub) {
|
||||
endpoint = sub.endpoint;
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
} catch { /* the server copy is removed below either way */ }
|
||||
if (all) return act("/api/admin/notifications/unsubscribe", "POST", { all: true });
|
||||
if (!endpoint) { notifications(); return; }
|
||||
return act("/api/admin/notifications/unsubscribe", "POST", { endpoint });
|
||||
}
|
||||
|
||||
/* ---------- Settings ---------- */
|
||||
|
||||
async function settings() {
|
||||
|
|
@ -812,7 +943,7 @@ async function act(path, method, body) {
|
|||
|
||||
/* ---------- Routing & events ---------- */
|
||||
|
||||
const RENDER = { overview, players, bans, comms, staff, verify, settings };
|
||||
const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications };
|
||||
|
||||
function renderNav() {
|
||||
const c = state.counts;
|
||||
|
|
@ -849,6 +980,7 @@ document.addEventListener("input", (e) => {
|
|||
|
||||
document.addEventListener("change", (e) => {
|
||||
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
|
||||
if (e.target.matches("[data-notif-time]")) saveNotifPrefs();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
|
|
@ -888,6 +1020,11 @@ document.addEventListener("click", (e) => {
|
|||
}
|
||||
|
||||
const sw = t.closest(".switch");
|
||||
if (sw && !sw.disabled && sw.matches("[data-ntype], [data-notif]")) {
|
||||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||||
saveNotifPrefs();
|
||||
return;
|
||||
}
|
||||
if (sw && !sw.disabled) {
|
||||
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
|
||||
sw.classList.add("dirty");
|
||||
|
|
@ -957,6 +1094,12 @@ document.addEventListener("click", (e) => {
|
|||
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
|
||||
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
|
||||
}
|
||||
case "notif-on": return enableNotifications();
|
||||
case "notif-off": return disableNotifications(false);
|
||||
case "notif-off-all":
|
||||
confirmDialog.reason = false;
|
||||
return confirmDialog("Turn off everywhere", "Turn notifications off on all your devices? Turn them on again on each device you want them on.", "Turn off", () => disableNotifications(true));
|
||||
case "notif-test": return act("/api/admin/notifications/test", "POST", {});
|
||||
case "save-settings": return saveSettings();
|
||||
case "discard": return settings();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue