A multi-session build turning Pete's read-only web UI into something people return to. Five phases, signed-in features keyed off the OIDC subject; anonymous visitors keep the reverse-chron feed and localStorage-only state. Phase 1 — per-user read + bookmark state: user_story_state table + storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a /bookmarks page; reader.js syncs state server-side for signed-in users. Also hides the Matrix-posting UI when posting.enabled=false (web-only mode). Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0 (content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and per-channel variants; <link rel=alternate> discovery tags. Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail + /for-you page; public /api/related feeds the reader's "You might also like". Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go (RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller; admin-gated /status page behind web.admin_subs. Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and sw.js (app-shell precache, /api/article runtime cache for offline reading, offline fallback, push/notificationclick handlers); PNG icons from pete.avif; pwa.js registers the SW and drives a notifications toggle. Web Push adds webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a digest sender that pings each subscriber "N new stories" past their watermark, honoring disabled-sources and pruning gone endpoints. Tests added beside each new storage/web file; go test ./... and go vet clean.
199 lines
6.9 KiB
JavaScript
199 lines
6.9 KiB
JavaScript
// Pete's service worker: an installable-PWA shell, an offline reader, and the
|
|
// Web Push receiver. Served from the root (/sw.js) so its scope is the whole
|
|
// origin — it can intercept navigations and /api/article the same as any page.
|
|
//
|
|
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
|
// drops every cache that doesn't match the current version.
|
|
var CACHE_VERSION = "v1";
|
|
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
|
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
|
|
|
// App shell: the static assets every page needs. Versioned by URL content only
|
|
// loosely, so we lean on network-first for HTML and cache-first for these.
|
|
var SHELL_ASSETS = [
|
|
"/static/css/output.css",
|
|
"/static/js/prefs.js",
|
|
"/static/js/weather.js",
|
|
"/static/js/weather-forecast.js",
|
|
"/static/js/search.js",
|
|
"/static/js/settings.js",
|
|
"/static/js/reader.js",
|
|
"/static/js/pwa.js",
|
|
"/static/img/pete.avif",
|
|
"/static/img/icon-192.png",
|
|
"/static/img/icon-512.png",
|
|
];
|
|
|
|
// How many visited-article responses to keep for offline reading before the
|
|
// oldest are evicted. Reader articles are small JSON blobs.
|
|
var RUNTIME_MAX = 60;
|
|
|
|
self.addEventListener("install", function (event) {
|
|
event.waitUntil(
|
|
caches.open(SHELL_CACHE).then(function (cache) {
|
|
// addAll is atomic-ish: if one asset 404s the whole install fails, so keep
|
|
// this list to assets we know are served. Individual failures are tolerated
|
|
// by falling back to per-asset puts.
|
|
return Promise.all(
|
|
SHELL_ASSETS.map(function (url) {
|
|
return cache.add(url).catch(function () {});
|
|
})
|
|
);
|
|
}).then(function () {
|
|
return self.skipWaiting();
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener("activate", function (event) {
|
|
event.waitUntil(
|
|
caches.keys().then(function (keys) {
|
|
return Promise.all(
|
|
keys.map(function (k) {
|
|
if (k !== SHELL_CACHE && k !== RUNTIME_CACHE) return caches.delete(k);
|
|
})
|
|
);
|
|
}).then(function () {
|
|
return self.clients.claim();
|
|
})
|
|
);
|
|
});
|
|
|
|
// trimCache evicts the oldest entries once a runtime cache passes its cap.
|
|
function trimCache(cacheName, max) {
|
|
caches.open(cacheName).then(function (cache) {
|
|
cache.keys().then(function (keys) {
|
|
if (keys.length <= max) return;
|
|
for (var i = 0; i < keys.length - max; i++) cache.delete(keys[i]);
|
|
});
|
|
});
|
|
}
|
|
|
|
// A minimal offline page for navigations we have nothing cached for.
|
|
function offlineFallback() {
|
|
return new Response(
|
|
"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>" +
|
|
"<title>Offline · Pete</title>" +
|
|
"<div style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;text-align:center;color:#3a2f1a\">" +
|
|
"<div style=font-size:3rem>🦆</div>" +
|
|
"<h1 style=font-size:1.4rem>You're offline</h1>" +
|
|
"<p style=opacity:.7>Pete can't reach the news right now. Articles you've already opened are still readable — head back and try one of those.</p>" +
|
|
"</div>",
|
|
{ headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 }
|
|
);
|
|
}
|
|
|
|
self.addEventListener("fetch", function (event) {
|
|
var req = event.request;
|
|
if (req.method !== "GET") return;
|
|
|
|
var url = new URL(req.url);
|
|
if (url.origin !== self.location.origin) return; // never touch cross-origin
|
|
|
|
// Visited articles: network-first so text stays fresh, but cache every success
|
|
// so the reader still works offline for stories the user has already opened.
|
|
if (url.pathname === "/api/article") {
|
|
event.respondWith(
|
|
fetch(req).then(function (res) {
|
|
if (res && res.ok) {
|
|
var copy = res.clone();
|
|
caches.open(RUNTIME_CACHE).then(function (cache) {
|
|
cache.put(req, copy);
|
|
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
|
|
});
|
|
}
|
|
return res;
|
|
}).catch(function () {
|
|
return caches.match(req).then(function (hit) {
|
|
return hit || new Response(JSON.stringify({ error: "offline" }), {
|
|
status: 503,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets: cache-first (they're versioned by deploy), fill the cache on
|
|
// first miss so a later offline visit has them.
|
|
if (url.pathname.indexOf("/static/") === 0) {
|
|
event.respondWith(
|
|
caches.match(req).then(function (hit) {
|
|
return hit || fetch(req).then(function (res) {
|
|
if (res && res.ok) {
|
|
var copy = res.clone();
|
|
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
|
}
|
|
return res;
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Page navigations: network-first, fall back to a cached copy of the same page,
|
|
// then to the offline card. Successful HTML is cached so revisits work offline.
|
|
if (req.mode === "navigate") {
|
|
event.respondWith(
|
|
fetch(req).then(function (res) {
|
|
if (res && res.ok) {
|
|
var copy = res.clone();
|
|
caches.open(RUNTIME_CACHE).then(function (cache) {
|
|
cache.put(req, copy);
|
|
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
|
|
});
|
|
}
|
|
return res;
|
|
}).catch(function () {
|
|
return caches.match(req).then(function (hit) {
|
|
return hit || offlineFallback();
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Everything else (other /api/* calls): straight to the network. These are
|
|
// personalized/stateful and must not be served stale.
|
|
});
|
|
|
|
// ---- Web Push -------------------------------------------------------------
|
|
// The server sends a JSON payload {title, body, url, tag}. Missing fields fall
|
|
// back to sensible defaults so a malformed push still shows something useful.
|
|
self.addEventListener("push", function (event) {
|
|
var data = {};
|
|
if (event.data) {
|
|
try { data = event.data.json(); } catch (e) { data = { body: event.data.text() }; }
|
|
}
|
|
var title = data.title || "Pete";
|
|
var options = {
|
|
body: data.body || "New stories are waiting.",
|
|
icon: "/static/img/icon-192.png",
|
|
badge: "/static/img/icon-192.png",
|
|
tag: data.tag || "pete-digest",
|
|
renotify: true,
|
|
data: { url: data.url || "/" },
|
|
};
|
|
event.waitUntil(self.registration.showNotification(title, options));
|
|
});
|
|
|
|
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 c = clients[i];
|
|
// Focus an existing Pete tab and route it to the target if we can.
|
|
if ("focus" in c) {
|
|
c.focus();
|
|
if ("navigate" in c && target !== "/") { try { c.navigate(target); } catch (e) {} }
|
|
return;
|
|
}
|
|
}
|
|
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
})
|
|
);
|
|
});
|