Fix desktop notifications not firing when Electron window is unfocused (#49)

Presence (which gates push, desktop notifications, and the unread dot)
only tracked document.visibilityState, which in Electron only flips on
minimize/hide -- not on losing OS focus, e.g. alt-tabbing away with the
window still open. That left the offline-audience computation treating
an unfocused-but-visible desktop window as "present," so notifications
never fired unless the app was actually minimized to tray.

Desktop mode now also requires document.hasFocus() before considering
a room joined; regular browser-tab behavior (visibility alone) is
unchanged. Verified in-browser: losing focus sends a leave frame,
regaining it sends join + gets acked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 12:31:48 -06:00
co-authored by Claude Sonnet 5
parent e0f85cec79
commit 344eb7ebf1
+41 -10
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { isDesktopNotificationsSupported } from '../lib/desktopBridge'
import { checkForUpdate } from '../lib/swUpdate'
import type { ServerEnvelope } from '../types'
@@ -9,6 +10,22 @@ interface UseChatSocketOptions {
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30000
// #49 follow-up: a bare `document.visibilityState` check (below) means "not
// minimized/hidden" -- in a browser tab that's a decent proxy for "the user
// could be looking at this," since visibility already tracks whether this is
// the active tab. Inside an Electron BrowserWindow it isn't: visibilityState
// only flips on minimize/hide, not on losing OS focus, so a window sitting
// open-but-unfocused behind another app never registers as "gone." That's
// exactly the state a desktop notification needs to fire in, so desktop mode
// additionally requires document.hasFocus(). Gated on desktopMode so regular
// browser-tab behavior (already relied on by Web Push and the unread dot) is
// completely unchanged.
const desktopMode = isDesktopNotificationsSupported()
function isPresent(): boolean {
return document.visibilityState === 'visible' && (!desktopMode || document.hasFocus())
}
// One connection per authenticated session, established as soon as the app
// shell mounts -- not per-room. A room is just something this socket can be
// told to "join"/"leave" while it's open; the connection itself persists
@@ -30,7 +47,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
// backgrounded user the same as a disconnected one instead of assuming a
// live WebSocket delivery the user can't actually see will do the job.
const desiredRoomsRef = useRef(new Set<string>())
const isVisibleRef = useRef(document.visibilityState === 'visible')
const isVisibleRef = useRef(isPresent())
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
const ws = socketRef.current
@@ -74,7 +91,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
// comment): reconnecting from a backgrounded tab should stay
// "left" for the same reason backgrounding leaves in the first
// place (see desiredRoomsRef's comment above).
isVisibleRef.current = document.visibilityState === 'visible'
isVisibleRef.current = isPresent()
if (isVisibleRef.current) {
for (const roomId of desiredRoomsRef.current) {
sendRoomFrame('join', roomId)
@@ -125,16 +142,30 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
}, [sendRoomFrame])
useEffect(() => {
function handleVisibilityChange() {
const visible = document.visibilityState === 'visible'
if (visible === isVisibleRef.current) return
isVisibleRef.current = visible
function handlePresenceChange() {
const present = isPresent()
if (present === isVisibleRef.current) return
isVisibleRef.current = present
for (const roomId of desiredRoomsRef.current) {
sendRoomFrame(visible ? 'join' : 'leave', roomId)
sendRoomFrame(present ? 'join' : 'leave', roomId)
}
}
document.addEventListener('visibilitychange', handlePresenceChange)
// Only in desktop mode -- see isPresent()'s comment above. Blur/focus on
// a browser tab fire on every click into/out of the page (e.g. opening
// devtools), which would be a far noisier signal than intended there;
// browser tabs stay on visibilitychange alone, unchanged from before.
if (desktopMode) {
window.addEventListener('focus', handlePresenceChange)
window.addEventListener('blur', handlePresenceChange)
}
return () => {
document.removeEventListener('visibilitychange', handlePresenceChange)
if (desktopMode) {
window.removeEventListener('focus', handlePresenceChange)
window.removeEventListener('blur', handlePresenceChange)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [sendRoomFrame])
const subscribe = useCallback((handler: (envelope: ServerEnvelope) => void) => {
@@ -162,7 +193,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
// room stays in desiredRoomsRef regardless, so the next genuine
// foreground transition (handleVisibilityChange below) still joins
// it, just deferred instead of wrongly immediate.
isVisibleRef.current = document.visibilityState === 'visible'
isVisibleRef.current = isPresent()
if (isVisibleRef.current) sendRoomFrame('join', roomId)
},
[sendRoomFrame],