Add desktop notification bridge for DS Chat Desktop (#49)

Offline members now also get a desktop_notification WS envelope
alongside the existing Web Push send, since Electron has no push
delivery service configured. The client only acts on it when
window.dsDesktop is present and the user's local preference allows it,
so the server needs no awareness of which clients are Electron.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 10:27:20 -06:00
co-authored by Claude Sonnet 5
parent 2a84a9c9bd
commit e0f85cec79
8 changed files with 399 additions and 14 deletions
+34
View File
@@ -311,6 +311,40 @@ that point, so nothing online-facing is delayed, and it sidesteps
on. An expired/invalid subscription (pywebpush 404/410) is deleted
automatically.
## Desktop notifications
DS Chat Desktop (a separate Electron wrapper, not this repo) has no push
delivery service configured, so it can't receive Web Push. Instead,
`app/services/message_events.py`'s existing offline-member computation
(`room members - Presence.connected_user_ids(room_id) - {sender}` — the
same audience Web Push uses, described above) also broadcasts a
`desktop_notification` WS envelope (`{type, id, room_id, title, body}`,
`id` being the message's own id so the client can dedupe across
reconnects) to every eligible offline member over their already-open
authenticated socket, unconditionally — the server has no notion of which
clients are running inside Electron. It's sent alongside the Web Push
send, not instead of it, so a member with only a browser tab open is
unaffected.
The client decides whether to act on it: `frontend/src/lib/desktopBridge.ts`
feature-detects `window.dsDesktop` (the bridge Electron's preload script
exposes, per-method rather than via user-agent sniffing — an older wrapper
build may be missing individual methods) and only calls
`showNotification` when the bridge is present and the user's
localStorage-backed preference (`ds-chat-desktop-notifications-enabled`,
default on) allows it. This preference is deliberately a plain client-side
flag rather than reusing `PushSubscription` — desktop notifications need
no server round trip to enable/disable, unlike a push subscription which
has a row to create/delete. `frontend/src/components/DesktopNotificationBridge.tsx`
is mounted once, as a sibling of the routed pages inside the `user.id`-keyed
`ChatSocketProvider`, so it subscribes exactly once per authenticated
session; it also wires `window.dsDesktop.onNotificationClick` to navigate
to the notification's room.
No `User`/`PushSubscription` schema change was needed for this feature —
the only backend change is the new `desktop_notification` envelope type,
covered by `backend/tests/test_desktop_notifications.py`.
## Room roles and membership (Phase 2)
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or