Replace Web Push and its service worker with page notifications over SSE

This commit is contained in:
Ian Gulliver
2026-10-15 18:34:53 -07:00
parent c7f19f2537
commit a1615693ef
32 changed files with 429 additions and 1175 deletions
+2 -109
View File
@@ -28,7 +28,7 @@ export interface paths {
cookie?: never;
};
get?: never;
/** @description Sets which inbox items the calling user is sent push notifications for. */
/** @description Sets which new inbox items notify the calling user in their open byre pages. */
put: operations["updateNotificationPrefs"];
post?: never;
delete?: never;
@@ -37,23 +37,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/push-subscriptions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** @description Registers a browser's push subscription for the calling user. Registering an endpoint again replaces it. */
post: operations["addPushSubscription"];
delete: operations["removePushSubscription"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/lead/sessions": {
parameters: {
query?: never;
@@ -122,22 +105,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/push/key": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getPushKey"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects": {
parameters: {
query?: never;
@@ -780,7 +747,7 @@ export interface components {
git_email: string;
notification_prefs: components["schemas"]["NotificationPrefs"];
};
/** @description Which new inbox items send this user a push notification. */
/** @description Which new inbox items notify this user in their open byre pages. */
NotificationPrefs: {
/** @description Questions asked with blocking: true. On by default. */
blocking_questions: boolean;
@@ -793,17 +760,6 @@ export interface components {
/** @description rate_limited items. */
rate_limits: boolean;
};
PushSubscription: {
endpoint: string;
/** @description The subscription's p256dh key */
p256dh: string;
/** @description The subscription's auth secret */
auth: string;
};
PushKey: {
/** @description Base64url */
public_key: string;
};
UserUpdate: {
git_name: string;
git_email: string;
@@ -1320,49 +1276,6 @@ export interface operations {
400: components["responses"]["Error"];
};
};
addPushSubscription: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PushSubscription"];
};
};
responses: {
/** @description Registered. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
400: components["responses"]["Error"];
};
};
removePushSubscription: {
parameters: {
query: {
endpoint: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Removed, or was not registered. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listLeadSessions: {
parameters: {
query?: never;
@@ -1477,26 +1390,6 @@ export interface operations {
400: components["responses"]["Error"];
};
};
getPushKey: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The server's VAPID public key, the applicationServerKey for PushManager.subscribe. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PushKey"];
};
};
};
};
listProjects: {
parameters: {
query?: never;
+14 -8
View File
@@ -83,15 +83,21 @@ async function main(): Promise<void> {
addEventListener("hashchange", route);
route();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.onmessage = (e: MessageEvent<{ open: string }>) => {
location.hash = new URL(e.data.open, location.origin).hash;
};
await navigator.serviceWorker.register("/sw.js");
}
}
main().catch((err: unknown) => {
async function start(): Promise<void> {
if ("serviceWorker" in navigator) {
for (const reg of await navigator.serviceWorker.getRegistrations()) {
await reg.unregister();
}
if (navigator.serviceWorker.controller) {
location.reload();
return;
}
}
await main();
}
start().catch((err: unknown) => {
replace(document.body, h("pre", { class: "fatal" }, `byre failed to start: ${errorText(err)}`));
});
+41
View File
@@ -0,0 +1,41 @@
import type { InboxItem, NotificationPrefs } from "./api.js";
import { findTask, focusTask, state } from "./state.js";
function wants(p: NotificationPrefs, item: InboxItem): boolean {
switch (item.kind) {
case "question":
return item.blocking ? p.blocking_questions : p.questions;
case "report":
return p.reports;
case "session_error":
return p.errors;
case "rate_limited":
return p.rate_limits;
}
}
function itemText(item: InboxItem): string {
const p = item.payload;
const text =
item.kind === "question" ? String(p["question"] ?? "")
: item.kind === "report" ? String(p["text"] ?? "")
: item.kind === "session_error" ? `Error: ${String(p["error"] ?? "")}`
: "Rate limited";
const line = text.trim().split("\n")[0] ?? "";
return line.length > 200 ? `${line.slice(0, 199)}…` : line;
}
export function notifyInbox(item: InboxItem): void {
if (!("Notification" in window) || Notification.permission !== "granted" || !state.me || !wants(state.me.notification_prefs, item)) {
return;
}
const title = findTask(item.task_id)?.title;
const n = new Notification(title ? `byre · ${title}` : "byre", { body: itemText(item), tag: item.id });
n.onclick = () => {
window.focus();
if (item.task_id) {
focusTask(item.task_id);
}
n.close();
};
}
+15 -76
View File
@@ -1,4 +1,4 @@
import { del, get, post, put, type NotificationPrefs } from "./api.js";
import { put, type NotificationPrefs } from "./api.js";
import { errorText, h, openPanel, replace, showAlert } from "./dom.js";
import { on, state } from "./state.js";
@@ -42,94 +42,33 @@ function mountSettings(root: HTMLElement): () => void {
);
};
let disposed = false;
const renderDevice = async () => {
const status = await deviceStatus();
if (disposed) {
const renderDevice = () => {
if (!("Notification" in window)) {
replace(device, h("p", { class: "hint" }, "This browser doesn't support notifications."));
return;
}
if (status.kind === "unsupported") {
replace(device, h("p", { class: "hint" }, "This browser doesn't support web push."));
return;
}
if (status.kind === "denied") {
if (Notification.permission === "denied") {
replace(device, h("p", { class: "hint" }, "Notifications are blocked for this site in the browser's settings."));
return;
}
if (Notification.permission === "granted") {
replace(device, h("p", {}, "This browser shows notifications while a byre page is open."));
return;
}
const button = h("button", {}, status.sub ? "Turn off on this browser" : "Turn on for this browser");
const button = h("button", {}, "Turn on for this browser");
button.onclick = async () => {
button.disabled = true;
try {
if (status.sub) {
await disable(status.sub);
} else {
await enable(status.reg);
}
} catch (err) {
void showAlert(errorText(err));
}
await renderDevice();
await Notification.requestPermission();
renderDevice();
};
replace(device, h("p", {}, status.sub ? "This browser receives notifications." : "This browser doesn't receive notifications."), button);
replace(device, h("p", {}, "This browser doesn't show notifications."), button);
};
replace(root, h("div", { class: "settings-view" }, h("h2", {}, "Notifications"), device, prefs));
const off = on("me", renderPrefs);
renderPrefs();
void renderDevice().catch((err: unknown) => replace(device, h("p", { class: "error-text" }, errorText(err))));
renderDevice();
return () => {
disposed = true;
off();
};
}
type DeviceStatus =
| { kind: "unsupported" }
| { kind: "denied" }
| { kind: "ready"; reg: ServiceWorkerRegistration; sub: PushSubscription | null };
async function deviceStatus(): Promise<DeviceStatus> {
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
return { kind: "unsupported" };
}
if (Notification.permission === "denied") {
return { kind: "denied" };
}
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await register(sub);
}
return { kind: "ready", reg, sub };
}
async function enable(reg: ServiceWorkerRegistration): Promise<void> {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
return;
}
const key = await get("/api/push/key");
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: base64url(key.public_key) });
await register(sub);
}
async function disable(sub: PushSubscription): Promise<void> {
await del("/api/me/push-subscriptions", { query: { endpoint: sub.endpoint } });
await sub.unsubscribe();
}
async function register(sub: PushSubscription): Promise<void> {
const keys = sub.toJSON().keys ?? {};
await post("/api/me/push-subscriptions", { body: { endpoint: sub.endpoint, p256dh: keys["p256dh"] ?? "", auth: keys["auth"] ?? "" } });
}
function base64url(s: string): Uint8Array<ArrayBuffer> {
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
out[i] = bin.charCodeAt(i);
}
return out;
return off;
}
+4
View File
@@ -1,4 +1,5 @@
import { get, put, subscribe, type AgentSession, type ByreEvent, type Focus, type InboxItem, type Project, type Snapshot, type Task, type TaskTab, type User } from "./api.js";
import { notifyInbox } from "./notifications.js";
export type Topic =
| "me"
@@ -316,6 +317,9 @@ export function handleEvent(ev: ByreEvent): void {
}
break;
case "inbox.created":
notifyInbox(ev.payload as unknown as InboxItem);
void loadInbox();
break;
case "inbox.updated":
void loadInbox();
break;
-33
View File
@@ -1,33 +0,0 @@
const sw = self as unknown as ServiceWorkerGlobalScope;
type PushMessage = { title: string; body: string; url: string };
sw.addEventListener("install", () => {
void sw.skipWaiting();
});
sw.addEventListener("activate", (e) => {
e.waitUntil(sw.clients.claim());
});
sw.addEventListener("push", (e) => {
const msg = e.data?.json() as PushMessage;
e.waitUntil(sw.registration.showNotification(msg.title, { body: msg.body, data: { url: msg.url } }));
});
sw.addEventListener("notificationclick", (e) => {
e.notification.close();
const url = (e.notification.data as { url: string }).url;
e.waitUntil(openTask(url));
});
async function openTask(url: string): Promise<void> {
const windows = await sw.clients.matchAll({ type: "window", includeUncontrolled: true });
const win = windows[0];
if (!win) {
await sw.clients.openWindow(url);
return;
}
win.postMessage({ open: url });
await win.focus();
}