feat(push): suppress notifications to devices where the app is visible (presence-based send-time filter) #160
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#160
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Push notifications (chat needs-input/question, AFK done) are broadcast to every enrolled device — including the one where the operator is currently looking at the app. A device on which the app is visible/focused should not receive a push; all other enrolled devices still should.
Decision record (grill 2026-07-13)
document.visibilityState === 'visible'). A backgrounded tab or minimized PWA still gets notifications — the user isn't looking at it.showNotification(iOS revokes subscriptions after silent pushes; no documented focused-client exemption in WebKit). No SW fallback, no UA sniffing. A Chromium-only SW carve-out was considered and dropped./api/v1/events?conn=<uuid>).POST /api/v1/presencewith{conn, device, visible}; client sends it onvisibilitychange, once after the stream opens, and vianavigator.sendBeacononpagehide(closes the laptop-lid / app-swipe gap).crypto.subtle); server hashes stored endpoints when filtering. Zero schema change, capability URL never appears in URLs/logs, self-heals when the browser rotates the subscription.Sender.Broadcast()(internal/push/sender.go) — single choke point, presence view injected into the Sender. The chat/afk notify closures incmd/lab/main.gostay untouched (ADR-0038 boundary).Send()(single subscription) deliberately bypasses the filter, so the Settings "Send test" button always delivers.Mechanics & edge cases
conn → {deviceHash, visible}); restart clears it → everyone counts absent → notify (the safe direction).web/src/sse.ts(it already handlesvisibilitychange/focus/online/pageshow).push_subscriptionsrow, nothing to suppress.Broadcast, i.e. after the 2snotifyGatedebounce ininternal/chat/notify.go— presence is evaluated when the push actually fires.pagehideleaves a stale visible presence until the next SSE heartbeat write fails (~25s); a notification in that window is lost.sendBeacononpagehidenarrows this to rare cases.visibilitychange(beacon flips to hidden) and the OS suspends the page (SSE dies) — so a non-foregrounded iPhone reliably receives pushes. Every push that is delivered still shows a notification: Apple-compliant, SW unchanged.Touch points
internal/push/sender.go— presence interface + filter inBroadcast()internal/httpapior its own package) +POST /api/v1/presencehandlerinternal/httpapi/sse.go—connparam registration/deregistrationweb/src/sse.ts— conn UUID, beacon wiring (visibilitychange,pagehide, post-open)cmd/lab/main.go— wiringdocs/adr/00xx-presence-based-push-suppression.mdreferencing ADR-0038/0039 (written alongside the implementation)Acceptance
web/public/sw.jsunchanged: every delivered push still shows a notification.Landing audit — PR #162 → PASS
Verification signal relied on: Forgejo Actions
ci / native(the required check) — success, 5m48s, run 179. Not re-run locally. The hermeticci-nixgate did not run, correctly: it is path-gated to**/*.nix/flake.lock/go.mod/go.sum, none of which this diff touches, so no coverage was skipped.Conflicts: none —
afk/160merges cleanly into currentmain(probed withgit merge-tree).Conventions: title is Conventional Commits (
feat(push): …); body carries a workingCloses #160; ADR-0044 is the next free number (main is at 0043).Diff review (24 files, +1460/-31):
Broadcast()withSend()bypassing, multi-tab = ANY-live-AND-visible, suppressed = dropped, no settings knob.internal/presence.Registry:Updateno-ops on an unknown conn, so a stray beacon cannot resurrect a reaped entry;Visible("")is false, so an under-initialised tab cannot silence pushes. Every staleness path errs toward notifying, as decision 3 requires.internal/httpapi/sse.go:Connecthappens-before the 200 andDisconnectis deferred — the ordering is load-bearing and is correct.csrfMiddlewarewaives only theX-Lab-Csrfheader, only forPOST /api/v1/presence(exact path, exact method). The Origin checks stay fully enforced, including the reject-absent-Origin rule, so a cross-site page still cannot forge the request; the custom header was redundant defence on this endpoint, not the defence. The worst forgeable outcome is a wrong presence bit — an extra or a suppressed notification — never a data change. Pinned byTestCSRFPresenceBeaconWaivesHeader.Broadcast: each dispatch now gets its own payload copy (webpush-go pads the slice it is handed, in place).push.NewSendercaller updated;sw.jsis untouched (ADR-0039 holds); presence route is behindrequireAuthand stays unmounted when the registry is nil.Acceptance mapping verified against the tree:
TestBroadcastSuppressesVisibleDevice,TestSendBypassesPresence,TestSSERegistersPresenceForLifetimeOfStream, plusTestPresenceUnknownConnDoesNotResurrect/TestPresenceRequiresAuth/TestPresenceDisabledLeavesRouteUnmounted— all present.Non-blocking observations (accepted residuals, not regressions):
pagehideleaves a visible entry until the SSE heartbeat write fails, ~25s; a push in that window is lost) also covers a network-blip reconnect: the old server handler may not have noticed the dead socket while the new conn is already registered, and sinceVisibleis ANY-of, the device reads visible from the stale entry. Bounded by the same ~25s and biased the same way the design chose./api/v1/presenceinside the middleware; if the route is ever moved or gains a trailing-slash variant the waiver drifts silently. The test pins the current shape, so this is a note for future maintainers only.Verdict: PASS — no blockers. Merge is gated on the maintainer's explicit go-ahead.