Add personalization, outbound feeds, and PWA/push to the web UI
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.
This commit is contained in:
198
internal/web/static/sw.js
Normal file
198
internal/web/static/sw.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user