feat(notifications): Web Push for the web app, client side (#202) #489

Closed
jared wants to merge 1 commits from feat/web-push into lotus
Owner

Client half of #202: notifications in the web app while no Lotus tab is open.

Safe to merge on its own. Nothing registers until config.json gets a webPush entry for the homeserver, so today it only adds an unused push listener. The server half (a Sygnal push gateway) is planned on #202 and needs approval before anything is deployed.

Flow

Synapse sends an HTTP pusher request to the gateway (Sygnal, webpush kind). Sygnal signs it with VAPID and sends it through the browser's push service (FCM / Mozilla / Apple / WNS), encrypted to this browser's subscription. The service worker's push handler then shows the notification.

Changes

  • src/sw.ts
    • New push handler:
      • updates the app badge from unread;
      • skips showing anything while a Lotus tab is focused, since the in-app toast covers that;
      • otherwise shows the notification, silently while paused or in quiet hours. Browsers require a visible notification per push, so it can't be skipped.
    • Prefs (desktop notifications on/off, pause, quiet hours) are mirrored from the page into Cache Storage, so they still apply with no tab open.
    • Clicks:
      • forward the room, so the page can choose /direct/ or /home/;
      • a click with no tab open is held for up to 60 s until the new window asks for it (getPendingClick), so it lands in the right room instead of the home page. This also applies to the existing in-app OS notifications.
  • src/swPush.ts: a pure function from the payload to a notification, using Sygnal webpush's flattened fields. It follows the in-app OS notification rules:
    • logo icon;
    • room / room:thread tag, so a push and the page's own notification replace each other instead of stacking;
    • invites go to the invites page, and calls get their own tag and stay until handled;
    • no message text from encrypted rooms;
    • media described in words.
  • src/app/hooks/useWebPush.ts, plus pure helpers in utils/webPush.ts:
    • subscribes with the gateway's VAPID key, re-subscribing if the key changes;
    • registers an HTTP pusher: pushkey is the p256dh key, data has {url, endpoint, auth, events_only: true, default_payload: {user_id}}, and append: true;
    • removes this browser's pusher and subscription when the setting is turned off;
    • never runs in the desktop app.
  • Config (useClientConfig): webPush: { "<server name>": { gatewayUrl, appId, vapidPublicKey } }. It's per homeserver like statusPages, so accounts on other homeservers never register. Malformed entries are ignored, and the gateway URL must be https.
  • Settings → Notifications: "Notify when Lotus is closed" (web only, shown only when a gateway is configured; device-local; on by default). It's disabled until desktop notifications are allowed and on.

Tests

  • swPush.test.ts: 11 tests:
    • count-only pushes show nothing;
    • room messages, DMs, threads, media, long text;
    • encrypted rooms never show content or a Reply button;
    • invites and calls;
    • prefs validation;
    • pause and quiet hours.
  • webPush.test.ts: 7 tests:
    • per-homeserver config and its validation;
    • VAPID key base64url round-trip (65 bytes, 0x04 prefix);
    • subscription reuse only with the same key;
    • the exact /pushers/set body;
    • device names;
    • stored-pusher parsing.
  • Together with swReply, swShare, settings and settingsSync: 43/43. prettier and eslint are clean. Local tsc shows only the 5 errors from stale local deps, none in these files; CI typechecks with the lockfile.
  • Not run end to end: there's no gateway yet. That test is part of the server half.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA

Client half of #202: notifications in the web app while no Lotus tab is open. **Safe to merge on its own.** Nothing registers until `config.json` gets a `webPush` entry for the homeserver, so today it only adds an unused `push` listener. The server half (a Sygnal push gateway) is planned on #202 and needs approval before anything is deployed. ## Flow Synapse sends an HTTP pusher request to the gateway (Sygnal, webpush kind). Sygnal signs it with VAPID and sends it through the browser's push service (FCM / Mozilla / Apple / WNS), encrypted to this browser's subscription. The service worker's `push` handler then shows the notification. ## Changes - **`src/sw.ts`** - New `push` handler: - updates the app badge from `unread`; - skips showing anything while a Lotus tab is **focused**, since the in-app toast covers that; - otherwise shows the notification, **silently** while paused or in quiet hours. Browsers require a visible notification per push, so it can't be skipped. - Prefs (desktop notifications on/off, pause, quiet hours) are mirrored from the page into Cache Storage, so they still apply with no tab open. - **Clicks:** - forward the room, so the page can choose `/direct/` or `/home/`; - a click with no tab open is held for up to 60 s until the new window asks for it (`getPendingClick`), so it lands in the right room instead of the home page. This also applies to the existing in-app OS notifications. - **`src/swPush.ts`**: a pure function from the payload to a notification, using Sygnal webpush's flattened fields. It follows the in-app OS notification rules: - logo icon; - `room` / `room:thread` tag, so a push and the page's own notification replace each other instead of stacking; - invites go to the invites page, and calls get their own tag and stay until handled; - **no message text from encrypted rooms**; - media described in words. - **`src/app/hooks/useWebPush.ts`**, plus pure helpers in `utils/webPush.ts`: - subscribes with the gateway's VAPID key, re-subscribing if the key changes; - registers an HTTP pusher: `pushkey` is the p256dh key, `data` has `{url, endpoint, auth, events_only: true, default_payload: {user_id}}`, and `append: true`; - removes this browser's pusher and subscription when the setting is turned off; - never runs in the desktop app. - **Config** (`useClientConfig`): `webPush: { "<server name>": { gatewayUrl, appId, vapidPublicKey } }`. It's per homeserver like `statusPages`, so accounts on other homeservers never register. Malformed entries are ignored, and the gateway URL must be https. - **Settings → Notifications:** "Notify when Lotus is closed" (web only, shown only when a gateway is configured; device-local; on by default). It's disabled until desktop notifications are allowed and on. ## Tests - `swPush.test.ts`: 11 tests: - count-only pushes show nothing; - room messages, DMs, threads, media, long text; - encrypted rooms never show content or a Reply button; - invites and calls; - prefs validation; - pause and quiet hours. - `webPush.test.ts`: 7 tests: - per-homeserver config and its validation; - VAPID key base64url round-trip (65 bytes, 0x04 prefix); - subscription reuse only with the same key; - the exact `/pushers/set` body; - device names; - stored-pusher parsing. - Together with `swReply`, `swShare`, `settings` and `settingsSync`: **43/43**. prettier and eslint are clean. Local `tsc` shows only the 5 errors from stale local deps, none in these files; CI typechecks with the lockfile. - Not run end to end: there's no gateway yet. That test is part of the server half. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared added 1 commit 2026-11-09 17:48:19 -05:00
feat(notifications): Web Push for the web app, client side (#202)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Failing after 25s
CI / Build & Quality Checks (pull_request) Successful in 5m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 24m2s
99b0cf9c47
Background notifications while no Lotus tab is open. Inert until
config.json lists a push gateway for the account's homeserver
(`webPush: { "<server>": { gatewayUrl, appId, vapidPublicKey } }`).

- sw.ts: `push` handler. Shows the notification unless a Lotus tab is
  focused (the in-app toast covers that), silently during pause / quiet
  hours (prefs mirrored from the page into Cache Storage), updates the
  app badge. Clicks forward the room so the page can tell DMs apart; a
  click with no tab open is held until the new window asks for it.
- swPush.ts: pure payload → notification (Sygnal webpush fields). Same
  conventions as the in-app OS notifications: logo icon, room /
  room:thread tag, invites to the invites page, calls with their own
  tag, never any text from encrypted rooms, Reply only where the
  worker can send it.
- useWebPush: subscribes this browser with the gateway's VAPID key and
  registers an HTTP pusher (pushkey = p256dh, endpoint + auth in data,
  events_only, our user_id in default_payload); removes it again when
  turned off. Never in the desktop app.
- Settings → Notifications → "Notify when Lotus is closed" (web, only
  when a gateway is configured; device-local, on by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared closed this pull request 2026-11-09 19:11:43 -05:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#489