feat: push selected events to a phone over ntfy

Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.

The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.

- Best-effort delivery: a rejecting or unreachable ntfy server is logged
  and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
  cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
  save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
  secrets: stripped before the settings record is written, never returned
  by the API, which reports only `hasAuthToken`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 13:55:00 +01:00
1 parent 827eb3a1e0
commit 38d175e4a1
34 files changed
+1728 -8

No files matched your search

@@ -18,6 +18,9 @@
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.toggle-field span { display: grid; gap: .2rem; }
.toggle-field small { color: var(--muted); }
.settings-events { display: grid; gap: .6rem; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
.settings-events legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; }
.subreddit-heading h3 { margin: 0 0 .25rem; }
.subreddit-heading p { margin: 0; color: var(--muted); }
@@ -45,7 +48,7 @@
}
@media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.settings-navigation { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
.reddit-account-list {
display: grid;