379 lines
15 KiB
Markdown
379 lines
15 KiB
Markdown
# Morphit notifications system — design doc
|
|
|
|
**Status:** Phases 1, 2, 3, 4 ✅ ALL SHIPPED. Phase 3 (Web Push
|
|
for tab-closed delivery) landed in Part 122 cp13.
|
|
|
|
Shipped infrastructure lives at `apps/web/src/lib/notifications/`:
|
|
- `ambient.ts` — title-bar prefix, favicon canvas badge, App
|
|
Badging API.
|
|
- `native.ts` — Notification API (tab-open OS-level pings).
|
|
- `audio.ts` — opt-in audio cue (default off).
|
|
- `vibrate.ts` — opt-in vibration (default off, mobile only).
|
|
- `push.ts` — Web Push subscribe / unsubscribe client (Part 122
|
|
cp13).
|
|
- `preferences.ts` — per-category opt-in storage.
|
|
- `tradeNotifications.ts` — order / chat / feedback event
|
|
glue against the indexer streams.
|
|
- `crossPageTradeEvents.ts` — BroadcastChannel fan-out so the
|
|
same event in two open tabs only pings once.
|
|
- `index.ts` — public `notify(event)` surface called from
|
|
every notification site.
|
|
|
|
Backend-side push infrastructure (Part 122 cp13):
|
|
- `apps/relay/src/policy/pushSubscriptions.ts` — subscription
|
|
store (upsert / list / delete, failure-counter cleanup).
|
|
- `apps/relay/src/policy/pushSender.ts` — drains the
|
|
`push_pending` queue, encrypts payloads per RFC 8291 via the
|
|
`web-push` library, handles 410 Gone and transient failures.
|
|
- `apps/relay/src/api/push.ts` — `GET /v1/push/vapid-public-key`,
|
|
`POST /v1/push/subscribe`, `POST /v1/push/unsubscribe`.
|
|
- `apps/web/src/service-worker.ts` — `push` and
|
|
`notificationclick` handlers (dedup via tag, deeplink
|
|
open/focus).
|
|
- Schema v33 — `push_subscriptions` + `push_pending` tables
|
|
in `apps/indexer/src/db/schema.sql`.
|
|
- Indexer event emission — `feedback.ts` and `chat.ts` handlers
|
|
enqueue `push_pending` rows; chat routes order-permlink
|
|
messages under `category='order'` and other messages under
|
|
`category='chat'` so users can opt out of chat noise but
|
|
still get trade alerts.
|
|
|
|
`UnreadCounts` Readable store drives the title prefix +
|
|
favicon badge + the in-app inbox-tab unread dots.
|
|
|
|
The cp13 push-service-architecture decision: **user-selectable**
|
|
(self-hosted / standard / off), matching the existing
|
|
`push_notifications_privacy` FAQ entry. Operators ship a VAPID
|
|
keypair generated via `scripts/generate-vapid-keys.sh`; users
|
|
choose their privacy mode in Settings → Notifications.
|
|
Authentication on the subscribe endpoint for cp13 is
|
|
rate-limited-only (no cryptographic proof of account ownership);
|
|
the trade-off is documented in `docs/OPERATIONS.md` §42.5 — a
|
|
future checkpoint may add posting-key signature verification.
|
|
|
|
**Last updated:** 2026-05-15 (Part 122 cp13 — Phase 3 shipped);
|
|
prior shipping iterated across Phases 5d-F.
|
|
|
|
## Context
|
|
|
|
Trades should complete fast so users feel momentum. A user who places an
|
|
order, walks away, and doesn't find out it filled for 4 hours is a user
|
|
who doesn't place a second order today. Notifications convert "walked
|
|
away" into "came right back."
|
|
|
|
Directive: **use every reasonable channel — visual, audible, native OS —
|
|
without being annoying.**
|
|
|
|
## The platform reality (what actually works)
|
|
|
|
### Favicon badge (canvas-redraw technique)
|
|
|
|
Works by rendering the current favicon into a `<canvas>`, drawing a
|
|
colored dot + count over it, and swapping the result into
|
|
`<link rel="icon">` as a data URI.
|
|
|
|
- ✅ Chrome, Edge, Firefox desktop — works on the **live tab**.
|
|
- ❌ iOS Safari — favicon pinned at load, cannot be updated.
|
|
- ❌ Most Android mobile browsers — same.
|
|
- ❌ Bookmarks bar — Chrome caches bookmark favicons aggressively. A
|
|
new favicon in the tab does NOT propagate to an existing bookmark.
|
|
- ❌ Tab is closed → no JS runs → no badge.
|
|
|
|
**Verdict:** useful as a low-cost "you have unread items in this tab"
|
|
signal for users who keep Morphit open. Not a substitute for real
|
|
notifications.
|
|
|
|
### App Badging API (`navigator.setAppBadge`)
|
|
|
|
The actual native badge on OS-level app icons.
|
|
|
|
- ✅ Chrome, Edge desktop (installed PWA only)
|
|
- ✅ Safari 16.4+ on macOS/iOS (installed PWA only)
|
|
- ✅ Android Chrome (installed PWA only)
|
|
- ❌ Regular browser tab — does nothing.
|
|
- ❌ Firefox — not yet implemented (as of writing, check at impl time)
|
|
|
|
**Verdict:** the real answer for badge-on-icon when the user has the
|
|
PWA installed. Which many won't have.
|
|
|
|
### Web Push + service worker + Notification API
|
|
|
|
OS-native notification delivered even when the tab is closed.
|
|
|
|
- ✅ All major browsers on desktop (Chrome, Firefox, Edge, Safari 16+)
|
|
- ✅ Chrome on Android
|
|
- ❌ iOS Safari only if the PWA is installed to home screen (16.4+)
|
|
- ❌ Tor Browser at high security level — service workers disabled
|
|
- ❌ Firefox Focus, DuckDuckGo browser, etc. — often disabled
|
|
|
|
Requires user-granted permission. Requires a push service (we'd need to
|
|
set up a self-hosted push server or use a public VAPID-compatible one
|
|
like Mozilla's autopush or run our own).
|
|
|
|
**Verdict:** the single most important channel. Delivers when the tab is
|
|
closed, which is 95% of the use case.
|
|
|
|
### Native Notification API (tab-open only)
|
|
|
|
`new Notification("title", { body })` — OS-level notification but only
|
|
fires while the page has a live JS context. No service worker needed.
|
|
|
|
- ✅ Broad support on desktop
|
|
- ✅ Mobile browsers with open tabs
|
|
- ❌ Tab closed → no delivery
|
|
|
|
**Verdict:** simpler than Push API, no server infrastructure, but only
|
|
covers the "tab is open but not focused" case. Still valuable.
|
|
|
|
### Audio
|
|
|
|
`new Audio('/notify.mp3').play()` — straightforward but subject to
|
|
autoplay policies. User must have interacted with the page at least
|
|
once. Loud audio is annoying; use a short, quiet cue.
|
|
|
|
- ✅ All browsers, once user has interacted with page.
|
|
- ⚠️ Autoplay blocking on first page load.
|
|
- ⚠️ Disruptive if user is on a call, meeting, or has headphones in at
|
|
high volume. MUST be opt-in.
|
|
|
|
### Vibration (mobile)
|
|
|
|
`navigator.vibrate([200, 100, 200])` — tactile notification on mobile.
|
|
|
|
- ✅ Android
|
|
- ❌ iOS (vibrate API removed)
|
|
- ⚠️ Spam-y if overused. MUST be opt-in.
|
|
|
|
### Title-bar pulsing
|
|
|
|
Update `document.title` to prefix unread count: `"(3) Morphit —
|
|
Orderbook"`. Ancient technique, works everywhere, no permissions needed.
|
|
|
|
- ✅ Every browser, every platform.
|
|
- ⚠️ Subtle — users not looking at the tab list won't see it.
|
|
|
|
## Recommended channel stack
|
|
|
|
Use ALL of these simultaneously when applicable:
|
|
|
|
1. **Title-bar prefix `"(N) Morphit — ..."`** — always on, no permission
|
|
needed, no platform support question. Free.
|
|
2. **Favicon canvas badge** — always on, free, works for the live tab
|
|
on desktop. No permission.
|
|
3. **App Badging API** — always on when available (installed PWA only).
|
|
No permission beyond PWA install. Free.
|
|
4. **Notification API (tab-open)** — requires permission; opt-in per
|
|
category (chat, orders, feedback). Triggers when event arrives AND
|
|
tab is not focused.
|
|
5. **Web Push (tab-closed)** — requires permission; requires push
|
|
server infrastructure; opt-in per category. Triggers regardless of
|
|
tab state.
|
|
6. **Audio cue** — OFF by default. Opt-in in Settings. Short, quiet
|
|
sound. Plays only when tab is not focused (no sense pinging a user
|
|
who's staring at the page).
|
|
7. **Vibration** — OFF by default. Opt-in in Settings, mobile only.
|
|
|
|
## Annoyance-minimization policy
|
|
|
|
These rules are the actual UX contract — they're more important than
|
|
the technical stack:
|
|
|
|
### Never notify when the user is looking
|
|
|
|
Tab is `document.visibilityState === 'visible'` AND page has focus →
|
|
suppress ALL notifications except the title-bar prefix and favicon
|
|
badge (which are ambient, not alerts). The user doesn't need a ping
|
|
about a message they're already staring at.
|
|
|
|
### Coalesce bursts
|
|
|
|
If 3 events of the same category fire within 30 seconds, produce ONE
|
|
coalesced notification: "3 new messages from @alice" instead of three
|
|
separate pings. Debounce window resets on each event.
|
|
|
|
### Per-category granularity
|
|
|
|
Three independent opt-in toggles in Settings:
|
|
|
|
- **Order events** — your order filled, expired, was replaced by
|
|
counterparty offer, etc. RECOMMENDED ON. Highest signal-to-noise.
|
|
- **Chat messages** — new message in an active trade chat. DEFAULT
|
|
OFF for high-volume traders; DEFAULT ON for low-volume.
|
|
- **Feedback events** — someone left you feedback, or someone
|
|
responded to your feedback. DEFAULT ON.
|
|
|
|
Plus two orthogonal opt-ins:
|
|
- **Audible cue** — DEFAULT OFF.
|
|
- **Vibration** (mobile) — DEFAULT OFF.
|
|
|
|
### Respect OS-level DND
|
|
|
|
Browsers/OSs generally handle this for us — if the user has "Do Not
|
|
Disturb" or "Focus" on, notifications get suppressed automatically.
|
|
We don't need to detect this explicitly.
|
|
|
|
### Request permission at point of relevance
|
|
|
|
NOT on page load. First time a relevant event occurs (a message
|
|
arrives, an order fills), show an in-app banner: "You have a new
|
|
message. Want to know about future ones even when you're not here?
|
|
[Enable notifications] [Not now]". Roughly 3x the grant rate of
|
|
page-load prompts.
|
|
|
|
If user declines, don't ask again for a week. If they decline twice,
|
|
don't ask again this month. If they decline three times, never ask
|
|
again (respect their decision, show a "Enable notifications" button
|
|
in Settings for them to opt in if they change their mind).
|
|
|
|
### Quiet hours
|
|
|
|
Users can set explicit quiet hours in Settings ("No audible/push
|
|
notifications between 22:00 and 07:00 local time"). Visual channels
|
|
(title, favicon, badge) still work during quiet hours — they're
|
|
ambient, not alerts.
|
|
|
|
### Kill switch
|
|
|
|
One-click "Mute all notifications for [1 hour / 4 hours / until I
|
|
turn them back on]" in Settings AND in the title-bar notification
|
|
widget itself. Must be trivial to silence.
|
|
|
|
## Privacy considerations
|
|
|
|
**Push notifications go through a push service.** Standard Web Push
|
|
architecture uses either:
|
|
- Mozilla's public push service (free, used by Firefox)
|
|
- Google's FCM (free, used by Chrome — but telemetry to Google)
|
|
- Self-hosted (operator runs their own)
|
|
|
|
Every operator should be able to pick. The content of push messages
|
|
is end-to-end encrypted per Web Push spec (the push service sees
|
|
only ciphertext), but the **existence of a push** is visible to the
|
|
push service. An anonymity-conscious user may not want Chrome's FCM
|
|
knowing "this user just got pinged by morphit.io."
|
|
|
|
**Mitigation**: Settings toggle offers three levels:
|
|
- "Self-hosted only" — only subscribe to operator's push server
|
|
(may not exist; falls back to tab-open notifications only).
|
|
- "Standard" — use browser-default push service.
|
|
- "Off" — no Web Push, tab-open notifications only.
|
|
|
|
Push message *content* is e2e encrypted in all cases, but "self-hosted
|
|
only" hides metadata from Google/Mozilla too.
|
|
|
|
## Phase plan
|
|
|
|
Ship in phases so the highest-value, lowest-risk pieces land first:
|
|
|
|
### Phase 1 — zero-permission channels (ship first)
|
|
|
|
1. Title-bar prefix with unread count.
|
|
2. Favicon canvas badge (live tab only).
|
|
3. App Badging API (installed PWA only, silently no-ops otherwise).
|
|
4. A small `notifications` store + module that owns the unread count
|
|
per category.
|
|
5. Settings UI stubs for the opt-ins (don't wire Notification/Push
|
|
channels yet; just persist the preferences).
|
|
|
|
Risk: ~zero. No permissions, no infra.
|
|
|
|
### Phase 2 — Notification API (tab-open)
|
|
|
|
1. Permission-request UX banner (deferred, at-point-of-relevance).
|
|
2. Settings toggle enabling per-category Notification API.
|
|
3. Coalescing + visibility-state logic.
|
|
|
|
Risk: low. No server infra. User opt-in gated.
|
|
|
|
### Phase 3 — Web Push (tab-closed)
|
|
|
|
1. Push server infrastructure decision (self-hosted vs. browser-default).
|
|
2. Service worker push event handler.
|
|
3. Per-operator push service registration.
|
|
4. The privacy-level toggle (self-hosted / standard / off).
|
|
|
|
Risk: medium. Requires operator infrastructure. Depends on service
|
|
worker caching decision being ratified too (since both use the same SW).
|
|
|
|
### Phase 4 — audio + vibration
|
|
|
|
1. Quiet cue sound asset (MIT-licensed, <5KB, <500ms).
|
|
2. Opt-in toggles in Settings.
|
|
3. Tab-not-focused check before playing.
|
|
|
|
Risk: ~zero. Opt-in by default.
|
|
|
|
## Foundational module shape
|
|
|
|
The notification abstraction needs to be ONE module that all call
|
|
sites use. Same shape regardless of which channels are active:
|
|
|
|
```typescript
|
|
// apps/web/src/lib/notifications/index.ts
|
|
export type NotificationCategory = 'order' | 'chat' | 'feedback';
|
|
|
|
export interface NotificationEvent {
|
|
category: NotificationCategory;
|
|
title: string; // visible headline
|
|
body: string; // 1-2 sentence preview
|
|
href?: string; // where clicking takes the user
|
|
id: string; // for deduplication + coalescing
|
|
}
|
|
|
|
export function notify(event: NotificationEvent): void;
|
|
export function markRead(category?: NotificationCategory): void;
|
|
export const unreadCount: Readable<Record<NotificationCategory, number>>;
|
|
```
|
|
|
|
All call sites (chat handler, order-fill observer, feedback arrival)
|
|
call `notify(...)`. The module fans out to the channels that are
|
|
enabled for that user, applies coalescing, applies visibility rules.
|
|
|
|
Settings stores the preferences. Channels query settings at
|
|
notify()-time, not at subscribe-time, so toggling in Settings takes
|
|
effect immediately.
|
|
|
|
## Decisions made (historical record)
|
|
|
|
All four open questions from the original design doc are now
|
|
resolved. Captured here so future contributors can read the
|
|
"why" without re-litigating settled choices.
|
|
|
|
1. **Phased ship plan** — ✅ All four phases shipped. Phases 1
|
|
and 2 in the original sprint; Phase 4 (audio + vibrate) close
|
|
behind; Phase 3 (Web Push) landed in Part 122 cp13 after the
|
|
wiring-completeness audit caught a "claim without code"
|
|
regression.
|
|
2. **Annoyance policy** — ✅ Shipped as designed: no notification
|
|
when the tab is focused, coalesce-30s on bursty events,
|
|
per-category opt-in (order ON, feedback ON, chat OFF by
|
|
default), audio + vibrate OFF by default, permission requested
|
|
at the point of relevance not on page-load.
|
|
3. **Push service architecture** — ✅ User-selectable
|
|
(self-hosted / standard / off), matching the user-facing
|
|
design the FAQ entry `push_notifications_privacy` had already
|
|
set out. Operators ship a VAPID keypair generated via
|
|
`scripts/generate-vapid-keys.sh`; the client passes the
|
|
user's choice to the relay's `/v1/push/subscribe` endpoint at
|
|
subscribe time and the row's `privacy_mode` column records
|
|
it.
|
|
4. **Default states** — ✅ Chat default OFF. High-volume traders
|
|
need silence by default; users wanting per-message pings can
|
|
opt into chat events in Settings → Notifications. Order and
|
|
feedback events are ON by default because they're rare and
|
|
high-signal.
|
|
|
|
The push-sender worker (`apps/relay/src/policy/pushSender.ts`)
|
|
polls every 30 seconds (tunable), encrypts payloads per RFC 8291
|
|
via the `web-push` library, handles 410 Gone with
|
|
auto-cleanup, and drops events older than 1 hour to avoid
|
|
delivering stale notifications. Subscribe endpoint
|
|
authentication was rate-limited-only in cp13; cp14 (Part 122)
|
|
added posting-key signature verification — every subscribe
|
|
request must carry a signature over
|
|
`morphit:push:subscribe:<account>:<sha256(endpoint)>:<timestamp>`
|
|
verified against the account's posting public key from chain.
|
|
±5 minute skew accepted. Push payload strings are localized at
|
|
indexer-enqueue time using the user's locale recorded in
|
|
`push_subscriptions.locale` (also cp14); ten locales supported
|
|
matching the client's i18n set.
|