Private
Public Access
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:
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { ChatSocketProvider } from './context/ChatSocketContext'
|
||||
import { AdminRoute } from './components/AdminRoute'
|
||||
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
@@ -55,7 +56,12 @@ function AppRoutes() {
|
||||
// is currently open.
|
||||
const { user } = useAuth()
|
||||
if (!user) return routes
|
||||
return <ChatSocketProvider key={user.id}>{routes}</ChatSocketProvider>
|
||||
return (
|
||||
<ChatSocketProvider key={user.id}>
|
||||
<DesktopNotificationBridge />
|
||||
{routes}
|
||||
</ChatSocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()}>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// #49: the native bridge DS Chat Desktop (a separate Electron wrapper, not
|
||||
// this repo) exposes through a context-isolated preload script. Optional on
|
||||
// `Window` -- absent entirely in every browser, and even inside the Electron
|
||||
// shell a given method may be missing if the wrapper is an older build (see
|
||||
// isDesktopNotificationsSupported/isDesktopClickListenerSupported below,
|
||||
// each independently feature-tested rather than assumed present together).
|
||||
export interface DesktopNotificationRequest {
|
||||
eventId: string
|
||||
roomId: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dsDesktop?: {
|
||||
setUnreadCount?(unreadRoomCount: number): void
|
||||
showNotification?(notification: DesktopNotificationRequest): void
|
||||
onNotificationClick?(callback: (roomId: string) => void): () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Field limits Electron enforces on its side (documented in #49) -- applied
|
||||
// here too, defensively, right at the bridge boundary rather than upstream
|
||||
// in the shared notification-payload construction (server-side and Web
|
||||
// Push have no such constraint; this is specifically the desktop bridge's
|
||||
// contract, not a general notification-payload rule).
|
||||
const MAX_TITLE_LENGTH = 100
|
||||
const MAX_BODY_LENGTH = 500
|
||||
const MAX_ID_LENGTH = 128
|
||||
|
||||
export function isDesktopNotificationsSupported(): boolean {
|
||||
return typeof window.dsDesktop?.showNotification === 'function'
|
||||
}
|
||||
|
||||
export function isDesktopClickListenerSupported(): boolean {
|
||||
return typeof window.dsDesktop?.onNotificationClick === 'function'
|
||||
}
|
||||
|
||||
// No-ops silently if the bridge or this specific method isn't present --
|
||||
// callers don't need to guard, matching the rest of this module's
|
||||
// capability-detect-per-method philosophy (see #49's "feature-test each
|
||||
// bridge method before calling it").
|
||||
export function showDesktopNotification(request: DesktopNotificationRequest): void {
|
||||
const show = window.dsDesktop?.showNotification
|
||||
if (!show) return
|
||||
show({
|
||||
eventId: request.eventId.slice(0, MAX_ID_LENGTH),
|
||||
roomId: request.roomId.slice(0, MAX_ID_LENGTH),
|
||||
title: request.title.slice(0, MAX_TITLE_LENGTH),
|
||||
body: request.body.slice(0, MAX_BODY_LENGTH),
|
||||
})
|
||||
}
|
||||
|
||||
// Returns an unsubscribe function, or undefined if the bridge doesn't
|
||||
// support click callbacks at all (older wrapper, or no bridge) -- callers
|
||||
// should treat a missing return the same as a no-op cleanup.
|
||||
export function onDesktopNotificationClick(callback: (roomId: string) => void): (() => void) | undefined {
|
||||
return window.dsDesktop?.onNotificationClick?.(callback)
|
||||
}
|
||||
|
||||
const PREFERENCE_KEY = 'ds-chat-desktop-notifications-enabled'
|
||||
|
||||
// Purely local -- unlike Web Push, desktop notifications need no server
|
||||
// round trip to enable/disable (no subscription row to create/delete), so
|
||||
// this is a plain localStorage flag, deliberately decoupled from
|
||||
// PushSubscription existence rather than reusing/repurposing it. Defaults
|
||||
// to enabled: once running inside the desktop app at all, off-by-default
|
||||
// would just mean the common case needs an extra click for no real benefit.
|
||||
export function getDesktopNotificationsEnabled(): boolean {
|
||||
return localStorage.getItem(PREFERENCE_KEY) !== 'false'
|
||||
}
|
||||
|
||||
export function setDesktopNotificationsEnabled(enabled: boolean): void {
|
||||
localStorage.setItem(PREFERENCE_KEY, String(enabled))
|
||||
}
|
||||
@@ -206,6 +206,20 @@ export interface ChatUnreadUpdateEnvelope {
|
||||
mentioned: boolean
|
||||
}
|
||||
|
||||
// #49: delivered over this same socket, alongside the existing Web Push
|
||||
// send, to every eligible offline member regardless of push-subscription
|
||||
// status -- see backend/app/services/message_events.py's
|
||||
// _notify_offline_members. `id` is the source message's own id (stable,
|
||||
// not random) so the desktop bridge's dedup can key on it across socket
|
||||
// reconnects/replays.
|
||||
export interface ChatDesktopNotificationEnvelope {
|
||||
type: 'desktop_notification'
|
||||
id: string
|
||||
room_id: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type ServerEnvelope =
|
||||
| ChatMessageEnvelope
|
||||
| ChatMessageUpdateEnvelope
|
||||
@@ -216,6 +230,7 @@ export type ServerEnvelope =
|
||||
| ChatRoomAddedEnvelope
|
||||
| ChatMemberUpdatedEnvelope
|
||||
| ChatUnreadUpdateEnvelope
|
||||
| ChatDesktopNotificationEnvelope
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user