Replace Web Push and its service worker with page notifications over SSE
This commit is contained in:
+2
-109
@@ -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
@@ -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)}`));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user