News that finds you, including when nothing of ours is open
The dots covered Reports and Messages from the day those sections existed. The announcement did not: only a chat reply produced an HX-Trigger, so a scheduled run that filed a report or posted into Messages lit a green dot in a corner and said nothing at all. That is precisely the arrival nobody is watching for -- a chat reply is one you asked for a moment ago and are probably looking at. So every kind announces, each with its own once-only flag, and the payload is a list of items rather than of titles, because a notification is a thing you click and a title cannot say where. One arrival, three channels, and they must not all fire. A toast for somebody looking at the page; a count in the tab title while it is hidden, cleared on focus; a system notification for somebody elsewhere entirely. The service worker is the only place that can tell them apart -- the server cannot see whether a window is focused and the page cannot see a push it did not receive -- so it stays quiet when one of its own windows has focus. And web push, hand-rolled against RFC 8291 and RFC 8292 with the cryptography already here for Fernet. It exists because everything else is polled by an open page, and the arrival worth interrupting somebody for is a schedule firing at seven in the morning with the laptop shut. The trade is real and is written down rather than glossed: the POST goes to Google's or Mozilla's push service, the payload is sealed end to end so they cannot read it, and what they do learn is that this server sent something and when. Opt-in per device, off until asked for, and the rest of the system works without it. Nothing else in LLeMbas contacts an outside service on its own. The encryption is tested by decrypting it back with an independent implementation of the specification's other half. There is no other way to know: a push service accepts the POST and forwards bytes it cannot read, so a wrong derivation is a notification that never appears, with a 201 in the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9761082fa1
commit
54ed030732
@@ -120,3 +120,72 @@ self.addEventListener("fetch", function (event) {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
Notifications that arrive with no page open.
|
||||
|
||||
This is the only part of LLeMbas that runs when nothing of ours is on screen,
|
||||
and it is why web push exists here at all: everything else is polled by an open
|
||||
page, which is exactly what is missing at seven in the morning when a schedule
|
||||
fires and the laptop is shut.
|
||||
|
||||
The payload was encrypted end to end (see services/push.py), so what arrives
|
||||
here is the first plaintext anybody but this browser and that server has seen.
|
||||
*/
|
||||
self.addEventListener("push", function (event) {
|
||||
var payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch (error) {
|
||||
payload = { title: "LLeMbas", body: "Something new arrived." };
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) {
|
||||
/* Somebody is looking at it. The page has its own toast and its own
|
||||
count in the tab title, and a system notification on top of those is
|
||||
the same news three times -- which is how notifications come to be
|
||||
switched off for good. This is the only place that can be known: the
|
||||
server cannot see whether a window is focused, and the page cannot see
|
||||
a push that it did not receive. */
|
||||
for (var i = 0; i < clients.length; i++) {
|
||||
if (clients[i].focused) return null;
|
||||
}
|
||||
return self.registration.showNotification(payload.title || "LLeMbas", {
|
||||
body: payload.body || "",
|
||||
/* One at a time. A browser left closed all day must not be opened to a
|
||||
stack of twelve. */
|
||||
tag: "lembas-" + (payload.kind || "unread"),
|
||||
renotify: true,
|
||||
icon: "/static/img/icon-192.png",
|
||||
badge: "/static/img/icon-192.png",
|
||||
data: { url: payload.url || "/" },
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
Clicking one.
|
||||
|
||||
Focus a window that is already open rather than opening a second: somebody
|
||||
with LLeMbas open in a tab wants that tab, and `openWindow` would give them
|
||||
two. `navigate` moves the one they have to whatever arrived.
|
||||
*/
|
||||
self.addEventListener("notificationclick", function (event) {
|
||||
event.notification.close();
|
||||
var target = (event.notification.data && event.notification.data.url) || "/";
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) {
|
||||
for (var i = 0; i < clients.length; i++) {
|
||||
var client = clients[i];
|
||||
if (new URL(client.url).origin !== self.location.origin) continue;
|
||||
return client.focus().then(function (focused) {
|
||||
return focused && focused.navigate ? focused.navigate(target) : focused;
|
||||
});
|
||||
}
|
||||
return self.clients.openWindow(target);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+321
-13
@@ -434,22 +434,330 @@
|
||||
})();
|
||||
|
||||
/*
|
||||
Unread replies.
|
||||
Something arrived.
|
||||
|
||||
The sidebar polls /api/chats/unread; the response carries out-of-band spans
|
||||
for the dots and, when something has just landed, an HX-Trigger asking for a
|
||||
toast. Announcing it here rather than server-side keeps the wording and the
|
||||
timing in one place.
|
||||
*/
|
||||
document.addEventListener("lembas:unread", function (event) {
|
||||
var titles = (event.detail && event.detail.titles) || [];
|
||||
if (!titles.length || !window.lembas || !window.lembas.notify) return;
|
||||
for the dots and, when something has just landed, an HX-Trigger listing it.
|
||||
Announcing it here rather than server-side keeps the wording and the timing in
|
||||
one place.
|
||||
|
||||
var message = titles.length === 1
|
||||
? "Reply ready in “" + titles[0] + "”"
|
||||
: titles.length + " chats have new replies";
|
||||
window.lembas.notify(message, { kind: "success", timeout: 6000 });
|
||||
});
|
||||
Three things happen, and they are deliberately not the same thing three times:
|
||||
|
||||
- A **toast**, always. It is the answer for somebody who is looking at the
|
||||
page, and it is the only one of the three that needs no permission and
|
||||
cannot be switched off by an operating system.
|
||||
- A **count in the tab title**, while the tab is not the one being looked at.
|
||||
This is the part that was missing and that nothing else replaces: a schedule
|
||||
that fires while you are in another tab lit a green dot in a corner you
|
||||
could not see. Cleared the moment the page is looked at again, because a
|
||||
badge you have to dismiss is worse than none.
|
||||
- A **browser notification**, if the reader has asked for one. Only while the
|
||||
page is hidden -- notifying somebody about something they are watching
|
||||
happen is the behaviour that gets notifications turned off for good.
|
||||
*/
|
||||
(function () {
|
||||
var NOTIFY_KEY = "lembas-desktop-notifications";
|
||||
/* Kept per browser rather than on the account, because the *permission* is
|
||||
per browser and per origin. A preference that followed somebody to a
|
||||
machine where they had never granted it would be a switch that reads "on"
|
||||
and does nothing. */
|
||||
var baseTitle = document.title;
|
||||
var pending = 0;
|
||||
|
||||
function wanted() {
|
||||
try {
|
||||
return window.localStorage.getItem(NOTIFY_KEY) === "1";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setWanted(on) {
|
||||
try {
|
||||
window.localStorage.setItem(NOTIFY_KEY, on ? "1" : "0");
|
||||
} catch (error) { /* private mode; the toast still works */ }
|
||||
}
|
||||
|
||||
function retitle() {
|
||||
document.title = pending > 0 ? "(" + pending + ") " + baseTitle : baseTitle;
|
||||
}
|
||||
|
||||
/* The title is rewritten by navigation and by a rename arriving out of band,
|
||||
so the base is re-read rather than captured once. Without this, renaming a
|
||||
chat while something is unread would pin the old name until a reload. */
|
||||
function rebase() {
|
||||
var shown = document.title;
|
||||
var stripped = shown.replace(/^\(\d+\)\s*/, "");
|
||||
if (stripped !== baseTitle) { baseTitle = stripped; retitle(); }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!pending) return;
|
||||
pending = 0;
|
||||
retitle();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) clear();
|
||||
});
|
||||
window.addEventListener("focus", clear);
|
||||
|
||||
function describe(items) {
|
||||
if (items.length > 1) return items.length + " new arrivals";
|
||||
var item = items[0];
|
||||
if (item.kind === "report") return "Report filed: “" + item.title + "”";
|
||||
if (item.kind === "message") return "New message";
|
||||
return "Reply ready in “" + item.title + "”";
|
||||
}
|
||||
|
||||
/* `registration.showNotification` where there is a service worker, because
|
||||
`new Notification()` throws outright on Android Chrome -- so the plain
|
||||
constructor alone would work on every desktop it was tested on and on no
|
||||
phone at all. */
|
||||
function show(message, url) {
|
||||
if (!wanted() || !("Notification" in window)) return;
|
||||
if (Notification.permission !== "granted") return;
|
||||
|
||||
/* `tag` collapses several into one: a browser left in the background for an
|
||||
hour must not come back to a stack of them. */
|
||||
var options = {
|
||||
body: message,
|
||||
tag: "lembas-unread",
|
||||
icon: "/static/img/icon-192.png",
|
||||
data: { url: url },
|
||||
};
|
||||
if (navigator.serviceWorker && navigator.serviceWorker.ready) {
|
||||
navigator.serviceWorker.ready
|
||||
.then(function (registration) { registration.showNotification("LLeMbas", options); })
|
||||
.catch(function () { plain(options, url); });
|
||||
return;
|
||||
}
|
||||
plain(options, url);
|
||||
}
|
||||
|
||||
function plain(options, url) {
|
||||
try {
|
||||
var notification = new Notification("LLeMbas", options);
|
||||
notification.onclick = function () { window.focus(); if (url) location.href = url; };
|
||||
} catch (error) { /* unsupported; the toast and the title still carry it */ }
|
||||
}
|
||||
|
||||
document.addEventListener("lembas:unread", function (event) {
|
||||
var items = (event.detail && event.detail.items) || [];
|
||||
if (!items.length) return;
|
||||
|
||||
var message = describe(items);
|
||||
if (window.lembas && window.lembas.notify) {
|
||||
window.lembas.notify(message, { kind: "success", timeout: 6000 });
|
||||
}
|
||||
if (document.hidden) {
|
||||
rebase();
|
||||
pending += items.length;
|
||||
retitle();
|
||||
show(message, items.length === 1 ? items[0].url : "");
|
||||
}
|
||||
});
|
||||
|
||||
/* Asking. `requestPermission` must be called from a gesture, so every path to
|
||||
it is a button somebody pressed -- a preference restored on load and acted
|
||||
on is refused by the browser with nothing said anywhere.
|
||||
|
||||
Shared by the Settings toggle and by the one-time offer below, because two
|
||||
copies of "ask, then interpret the three answers" is two places for the
|
||||
denied case to be got wrong. */
|
||||
function ask(onSettled) {
|
||||
if (!("Notification" in window)) {
|
||||
window.lembas.notify("This browser has no notifications to offer.", { kind: "error" });
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "denied") {
|
||||
window.lembas.notify(
|
||||
"Notifications are blocked for this site in your browser's own settings, " +
|
||||
"which is the only place that can be undone.",
|
||||
{ kind: "error", timeout: 8000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
Notification.requestPermission().then(function (result) {
|
||||
if (result !== "granted") {
|
||||
window.lembas.notify("Left off — nothing was changed.");
|
||||
if (onSettled) onSettled();
|
||||
return;
|
||||
}
|
||||
setWanted(true);
|
||||
if (onSettled) onSettled();
|
||||
subscribe().then(function (pushed) {
|
||||
window.lembas.notify(
|
||||
pushed
|
||||
? "Notifications on, including while LLeMbas is closed."
|
||||
: "Notifications on while LLeMbas is open.",
|
||||
{ kind: "success", timeout: 6000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Registering with the browser's push service.
|
||||
|
||||
This is what makes a notification arrive with nothing of ours running --
|
||||
the poll above needs an open page, and the case worth notifying about is a
|
||||
schedule firing at seven in the morning.
|
||||
|
||||
Best effort, always. It needs a service worker (so HTTPS or localhost), a
|
||||
push service the browser can reach, and a `PushManager` that some browsers
|
||||
do not have; every one of those fails to "notifications while LLeMbas is
|
||||
open", which still works. A permission granted and a subscription refused
|
||||
must not read as a failure, because most of the feature is still there.
|
||||
*/
|
||||
function subscribe() {
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return fetch("/api/push/key")
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !data.key) return false;
|
||||
return navigator.serviceWorker.ready.then(function (registration) {
|
||||
return registration.pushManager.subscribe({
|
||||
/* Required, and not merely conventional: a browser refuses a
|
||||
subscription that does not promise every push will be shown to
|
||||
somebody. It is also why the worker's `push` handler always ends
|
||||
in a notification unless a window is focused. */
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: bytes(data.key),
|
||||
});
|
||||
}).then(function (subscription) {
|
||||
return fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
}).then(function (response) { return response.ok; });
|
||||
});
|
||||
})
|
||||
.catch(function () { return false; });
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
if (!("serviceWorker" in navigator)) return Promise.resolve();
|
||||
return navigator.serviceWorker.ready
|
||||
.then(function (registration) { return registration.pushManager.getSubscription(); })
|
||||
.then(function (subscription) {
|
||||
if (!subscription) return null;
|
||||
var endpoint = subscription.endpoint;
|
||||
return subscription.unsubscribe().then(function () {
|
||||
return fetch("/api/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: endpoint }),
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function () { return null; });
|
||||
}
|
||||
|
||||
/* base64url to bytes. `applicationServerKey` wants the raw 65-byte point and
|
||||
will not take the string, and `atob` will not take base64url -- the two
|
||||
substitutions and the padding are the whole of this. */
|
||||
function bytes(text) {
|
||||
var padded = (text + "===".slice((text.length + 3) % 4))
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
var raw = window.atob(padded);
|
||||
var out = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
var button = event.target.closest("[data-notify-toggle]");
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (wanted()) {
|
||||
setWanted(false);
|
||||
// The registration goes too. Leaving it would mean this server kept
|
||||
// sending to a browser that has been told to stop showing them, which is
|
||||
// traffic to a third party for something switched off.
|
||||
unsubscribe();
|
||||
paint(button);
|
||||
return;
|
||||
}
|
||||
ask(function () { paint(button); });
|
||||
});
|
||||
|
||||
/*
|
||||
Offering, once.
|
||||
|
||||
The browser's own permission box cannot be called on page load and should
|
||||
not be: it appears with no context, and a box somebody dismisses without
|
||||
reading is a permission that can only be undone in browser settings they
|
||||
will never find. So the offer is ours first -- a themed dialog that says
|
||||
what the notifications are for -- and pressing its button is the gesture the
|
||||
browser needs.
|
||||
|
||||
Once, ever, per browser. "Not now" is recorded exactly as firmly as "yes":
|
||||
an offer that comes back is the thing that makes people block a site to
|
||||
silence it, and Settings has the switch for anybody who changes their mind.
|
||||
*/
|
||||
var ASKED_KEY = "lembas-notifications-asked";
|
||||
|
||||
function offer() {
|
||||
if (!document.body || !document.body.dataset.authenticated) return;
|
||||
if (!("Notification" in window) || Notification.permission !== "default") return;
|
||||
try {
|
||||
if (window.localStorage.getItem(ASKED_KEY)) return;
|
||||
window.localStorage.setItem(ASKED_KEY, "1");
|
||||
} catch (error) {
|
||||
return; // no way to remember having asked, so do not ask
|
||||
}
|
||||
if (!window.lembas || !window.lembas.confirm) return;
|
||||
|
||||
window.lembas.confirm({
|
||||
title: "Notifications",
|
||||
message:
|
||||
"Let LLeMbas tell you when a reply, a report or a scheduled run arrives " +
|
||||
"while you are looking at something else?",
|
||||
confirmLabel: "Turn on",
|
||||
cancelLabel: "Not now",
|
||||
}).then(function (yes) { if (yes) ask(scan); });
|
||||
}
|
||||
|
||||
function paint(button) {
|
||||
var on = wanted() && "Notification" in window && Notification.permission === "granted";
|
||||
button.textContent = on ? "Turn off notifications" : "Turn on notifications";
|
||||
button.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
var hint = document.querySelector("[data-notify-state]");
|
||||
if (!hint) return;
|
||||
if (!("Notification" in window)) {
|
||||
hint.textContent = "This browser has no notifications to offer.";
|
||||
} else if (Notification.permission === "denied") {
|
||||
hint.textContent =
|
||||
"Blocked for this site in your browser's settings, which is the only " +
|
||||
"place that can be undone.";
|
||||
} else if (on) {
|
||||
hint.textContent = "On in this browser. Nothing is shown while you are looking at the page.";
|
||||
} else {
|
||||
hint.textContent = "Off in this browser.";
|
||||
}
|
||||
}
|
||||
|
||||
function scan() {
|
||||
document.querySelectorAll("[data-notify-toggle]").forEach(paint);
|
||||
}
|
||||
|
||||
function start() {
|
||||
scan();
|
||||
/* After the page has settled rather than during it: the offer is a dialog,
|
||||
and one that appears while the shell is still being painted reads as an
|
||||
error rather than as a question. */
|
||||
setTimeout(offer, 1500);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
document.body && start();
|
||||
document.addEventListener("htmx:afterSettle", scan);
|
||||
})();
|
||||
|
||||
/*
|
||||
A toast asked for by the server.
|
||||
|
||||
Reference in New Issue
Block a user