Files
ds-chat/frontend/src/lib/desktopBridge.ts
T
ksmithandClaude Sonnet 5 3349cadea2 Fix desktop tray unread indicator: clear only on genuine room entry
The tray indicator must stay visible while a message is unread and
clear only when the user actually opens the room containing it --
never merely because the window was shown, focused, restored, or
opened from the tray.

Adds window.dsDesktop.roomOpened() (called exactly once per genuine
transition to a real room id, via useDesktopRoomBridge) and wires up
window.dsDesktop.setUnreadCount() (declared but never actually called
before this) to fire whenever the unread-room count changes. Both are
no-ops in browser builds, matching this bridge's existing optional-
chaining convention.

Frontend unread state stays authoritative -- clearRoomIndicators is
pulled out of ChatShellPage as a pure function (same behavior, now
directly testable) so opening one room only clears that room's
unread/mention flags, leaving every other room's state untouched.

Also adds a frontend test runner (vitest + React Testing Library) --
none existed in this project before. 10 new tests cover: roomOpened
firing once on entry, not refiring on an unrelated rerender with the
same room id, refiring on a genuine room-to-room transition, never
firing with no room id, setUnreadCount firing independently of
roomOpened, clearing one room preserving every other room's unread
state, and the desktop bridge remaining fully optional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:21:45 -06:00

94 lines
4.0 KiB
TypeScript

// #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
// Tells the tray/dock icon the user has genuinely entered a room --
// the desktop wrapper's own signal to clear that room's tray
// indicator. Deliberately not fired just because the window was
// shown/focused/restored, or opened from the tray: none of those mean
// the user actually looked at the room the unread message is in.
roomOpened?(): 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'
}
// Both no-op silently if the bridge or this specific method isn't present,
// same as showDesktopNotification below -- callers never need to guard.
export function notifyRoomOpened(): void {
window.dsDesktop?.roomOpened?.()
}
export function notifyUnreadCount(unreadRoomCount: number): void {
window.dsDesktop?.setUnreadCount?.(unreadRoomCount)
}
// 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))
}