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:
prosolis
2026-07-07 00:07:19 -07:00
parent 55aa167151
commit 71f7050f41
45 changed files with 3622 additions and 36 deletions

View File

@@ -45,16 +45,20 @@ func toView(s storage.Story) StoryView {
}
type pageData struct {
SiteTitle string
Channels []Channel
Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
AllSources template.JS // JSON array of {name, channel} for the settings panel
AuthEnabled bool // sign-in is available
User *SessionUser // nil for anonymous visitors
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
Path string // current request path, for post-login return
SiteTitle string
Channels []Channel
Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
AllSources template.JS // JSON array of {name, channel} for the settings panel
AuthEnabled bool // sign-in is available
User *SessionUser // nil for anonymous visitors
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
Path string // current request path, for post-login return
PostingEnabled bool // false = web-only mode; hide Matrix-posting UI
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
}
type channelPage struct {
@@ -74,6 +78,7 @@ type indexPage struct {
JustPosted []StoryView
Stats []channelStat
Latest []StoryView
ForYou []StoryView // personalized rail; empty for anon / no-history users
}
type channelStat struct {
@@ -105,17 +110,21 @@ func (s *Server) base(r *http.Request) pageData {
srcJSON = []byte("[]")
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
UserPrefs: template.JS("null"),
Path: r.URL.Path,
PostingEnabled: s.postingEnabled,
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
d.User = u
d.IsAdmin = s.adminSubs[u.Sub]
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
d.UserPrefs = jsForScript([]byte(blob))
}
@@ -176,9 +185,54 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
Stats: stats,
Latest: latest,
}
// For signed-in users with some read/bookmark history, lead with a small
// personalized rail. ForYou returns nothing for anon / no-history users, so
// the section simply doesn't render for them.
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
const forYouLimit = 8
if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil {
slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err)
} else {
for _, row := range fy {
data.ForYou = append(data.ForYou, toView(row))
}
}
}
}
s.render(w, "index", data)
}
type forYouPage struct {
pageData
Stories []StoryView
}
// handleForYou renders the dedicated personalized feed. Anonymous visitors are
// sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
u := s.auth.userFromRequest(r)
if u == nil {
http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther)
return
}
s.track(r, "for-you")
const limit = 30
rows, err := storage.ForYou(u.Sub, limit)
if err != nil {
slog.Error("web: for-you page failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
views := make([]StoryView, 0, len(rows))
for _, row := range rows {
views = append(views, toView(row))
}
base := s.base(r)
base.Active = "for-you"
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
}
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
s.track(r, ch.Slug)
page := 1
@@ -220,6 +274,64 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
s.render(w, "channel", data)
}
type bookmarksPage struct {
pageData
Stories []StoryView
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Total int
}
// handleBookmarks lists the signed-in user's bookmarked stories. Anonymous
// visitors are sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
u := s.auth.userFromRequest(r)
if u == nil {
http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther)
return
}
s.track(r, "bookmarks")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
offset := (page - 1) * pageSize
rows, err := storage.ListBookmarks(u.Sub, pageSize+1, offset)
if err != nil {
slog.Error("web: list bookmarks failed", "sub", u.Sub, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasNext := len(rows) > pageSize
if hasNext {
rows = rows[:pageSize]
}
views := make([]StoryView, 0, len(rows))
for _, row := range rows {
views = append(views, toView(row))
}
total, _ := storage.CountBookmarks(u.Sub)
base := s.base(r)
base.Active = "bookmarks"
data := bookmarksPage{
pageData: base,
Stories: views,
Page: page,
HasPrev: page > 1,
HasNext: hasNext,
PrevURL: fmt.Sprintf("/bookmarks?page=%d", page-1),
NextURL: fmt.Sprintf("/bookmarks?page=%d", page+1),
Total: total,
}
s.render(w, "bookmarks", data)
}
var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
demoIntensities = []string{"light", "medium", "heavy"}
@@ -309,6 +421,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}
// toSearchResults maps stories to the JSON shape shared by /api/search and
// /api/related, resolving each story's channel to its display metadata.
func toSearchResults(rows []storage.Story) []searchResult {
channelByName := make(map[string]Channel, len(channels))
for _, ch := range channels {
channelByName[ch.Slug] = ch
@@ -333,7 +451,50 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Posted: row.Posted,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": results})
return results
}
// handleRelated returns stories textually similar to a given story, for the
// "You might also like" rail in reader mode. It is public (reader mode works
// for anonymous visitors too); for signed-in users it drops already-read
// stories so recommendations stay fresh.
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
return
}
const relatedLimit = 6
// Over-fetch so dropping already-read stories doesn't starve the rail.
rows, err := storage.RelatedStories(id, relatedLimit*2)
if err != nil {
slog.Error("web: related failed", "id", id, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if s.auth != nil && len(rows) > 0 {
if u := s.auth.userFromRequest(r); u != nil {
ids := make([]int64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil {
kept := rows[:0]
for _, row := range rows {
if !read[row.ID] {
kept = append(kept, row)
}
}
rows = kept
}
}
}
if len(rows) > relatedLimit {
rows = rows[:relatedLimit]
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}
// handleArticle serves the stored full text of a single story for reader mode.