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
@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChatSocketContext } from '../context/ChatSocketContext'
import {
getDesktopNotificationsEnabled,
isDesktopNotificationsSupported,
onDesktopNotificationClick,
showDesktopNotification,
} from '../lib/desktopBridge'
import type { ServerEnvelope } from '../types'
// #49: renders nothing -- purely wires the authenticated socket's
// "desktop_notification" envelopes (see backend/app/services/
// message_events.py's _notify_offline_members) into DS Chat Desktop's
// native notification bridge, when running inside it. A no-op everywhere
// else (isDesktopNotificationsSupported() is false in every real browser).
//
// Mounted once, as a sibling of the routed pages inside ChatSocketProvider
// (App.tsx) -- that provider is already keyed by user.id and untouched by
// route changes, so this subscribes exactly once per authenticated session
// rather than accumulating a listener per navigation.
export function DesktopNotificationBridge() {
const socket = useChatSocketContext()
const navigate = useNavigate()
useEffect(() => {
return socket.subscribe((envelope: ServerEnvelope) => {
if (envelope.type !== 'desktop_notification') return
if (!isDesktopNotificationsSupported() || !getDesktopNotificationsEnabled()) return
showDesktopNotification({
eventId: envelope.id,
roomId: envelope.room_id,
title: envelope.title,
body: envelope.body,
})
})
}, [socket])
useEffect(() => {
const unsubscribe = onDesktopNotificationClick((roomId) => {
// Always an internal room id from our own server, never a URL --
// constructing the route here (not accepting a URL from the bridge)
// is the point, not an implementation detail.
navigate(`/rooms/${roomId}`)
})
return unsubscribe
}, [navigate])
return null
}
+38 -8
View File
@@ -6,11 +6,22 @@ import { ApiError } from '../api/client'
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import {
getDesktopNotificationsEnabled,
isDesktopNotificationsSupported,
setDesktopNotificationsEnabled,
} from '../lib/desktopBridge'
import { getPushSubscriptionStatus, isPushSupported, subscribeToPush, unsubscribeFromPush } from '../lib/push'
import { ProfileModal } from './ProfileModal'
import { UserAvatar } from './UserAvatar'
import './TopBar.css'
// #49: inside DS Chat Desktop, notifications are delivered over the socket
// bridge instead of Web Push (Electron has no push delivery service
// configured) -- checked once, not re-derived per render, since bridge
// presence can't change over a session's lifetime.
const desktopMode = isDesktopNotificationsSupported()
export function TopBar() {
const { user, updateUser, logout } = useAuth()
const navigate = useNavigate()
@@ -19,13 +30,26 @@ export function TopBar() {
const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false)
const [pushError, setPushError] = useState<string | null>(null)
const [desktopNotificationsEnabled, setDesktopNotificationsEnabledState] = useState(
getDesktopNotificationsEnabled,
)
const [presenceBusy, setPresenceBusy] = useState(false)
const [presenceError, setPresenceError] = useState<string | null>(null)
useEffect(() => {
// Never touch PushManager at all in desktop mode -- Electron has no
// push service configured, so even the read-only getSubscription()
// check has no reason to run there.
if (desktopMode) return
getPushSubscriptionStatus().then(setPushSubscribed)
}, [])
function handleToggleDesktopNotifications() {
const next = !desktopNotificationsEnabled
setDesktopNotificationsEnabled(next)
setDesktopNotificationsEnabledState(next)
}
async function handleTogglePush() {
setPushBusy(true)
setPushError(null)
@@ -119,15 +143,21 @@ export function TopBar() {
Admin
</button>
)}
{isPushSupported() && (
<button
type="button"
role="menuitem"
onClick={handleTogglePush}
disabled={pushBusy}
>
{pushSubscribed ? 'Disable notifications' : 'Enable notifications'}
{desktopMode ? (
<button type="button" role="menuitem" onClick={handleToggleDesktopNotifications}>
{desktopNotificationsEnabled ? 'Disable notifications' : 'Enable notifications'}
</button>
) : (
isPushSupported() && (
<button
type="button"
role="menuitem"
onClick={handleTogglePush}
disabled={pushBusy}
>
{pushSubscribed ? 'Disable notifications' : 'Enable notifications'}
</button>
)
)}
{pushError && <div className="top-bar-menu-error">{pushError}</div>}
<button type="button" role="menuitem" onClick={() => logout()}>