Send push notifications when the app is backgrounded, not just closed (#31)

Root cause (found earlier): the server only pushes to users it
believes are "connected" to a room, but that was a raw WebSocket-
connection check with no concept of whether the tab was actually
foregrounded -- a backgrounded-but-still-connected tab looked exactly
like someone actively watching, so the push got suppressed even
though nothing could surface on a hidden page.

No backend change needed: the server's presence tracking (and the
push-suppression logic built on it) was already correct for "not
joined to this room's channel" -- the gap was purely that the client
never told it about backgrounding. useChatSocket.ts now tracks
document.visibilityState and sends "leave" for every desired room
when hidden (without forgetting the app still wants them joined), and
"join" again when visible -- reusing the exact join/leave path a real
room switch already goes through, no new WS message type or backend
logic required.

Rejoining also triggers a message-history refetch in ChatPane (keyed
off the server's existing "joined" ack), so anything sent while
backgrounded gets backfilled instead of silently missing -- as a side
effect, this also fixes reconnect-after-a-dropped-connection never
backfilling either, which had the same gap.

Verified end-to-end: simulated backgrounding in the browser and
confirmed via direct Redis inspection that the room's presence hash
(what push-suppression actually reads) goes empty on hide and
repopulates with a message resync on show.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:58:39 -06:00
co-authored by Claude Sonnet 5
parent 7ef6cfca65
commit ccd92787d6
2 changed files with 78 additions and 23 deletions
+25 -4
View File
@@ -23,8 +23,11 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const [wsError, setWsError] = useState<string | null>(null) const [wsError, setWsError] = useState<string | null>(null)
const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false) const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false)
useEffect(() => { const refreshHistory = useCallback(() => {
setHistory([]) // live is cleared alongside history, not just on room switch: it's
// superseded by this fetch fully replacing history with the current
// authoritative list, so anything already in live would otherwise
// render twice once history was fetched.
setLive([]) setLive([])
setWsError(null) setWsError(null)
setHistoryUnavailableOffline(false) setHistoryUnavailableOffline(false)
@@ -39,6 +42,15 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
}) })
}, [room.id]) }, [room.id])
useEffect(() => {
// Blanks the previous room's messages immediately, rather than leaving
// them on screen until the fetch resolves -- refreshHistory itself
// deliberately doesn't do this (a resync on the *same* room shouldn't
// flash empty while refetching).
setHistory([])
refreshHistory()
}, [room.id, refreshHistory])
useEffect(() => { useEffect(() => {
socket.joinRoom(room.id) socket.joinRoom(room.id)
return () => socket.leaveRoom(room.id) return () => socket.leaveRoom(room.id)
@@ -53,7 +65,16 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
// that misattributing one to the wrong room's banner isn't worth // that misattributing one to the wrong room's banner isn't worth
// guarding against separately. // guarding against separately.
socket.subscribe((envelope: ServerEnvelope) => { socket.subscribe((envelope: ServerEnvelope) => {
if (envelope.type === 'message' && envelope.room_id === room.id) { if (envelope.type === 'joined' && envelope.room_id === room.id) {
// Fires on initial join (a harmless redundant fetch right after
// the mount effect's own) and, more importantly, on every
// rejoin -- coming back from a backgrounded tab (see
// useChatSocket's visibility handling) or reconnecting after a
// dropped connection. Either way, messages could have arrived
// while this socket wasn't in the room's channel, so resync
// instead of trusting whatever's already in state.
refreshHistory()
} else if (envelope.type === 'message' && envelope.room_id === room.id) {
setLive((prev) => [...prev, envelope]) setLive((prev) => [...prev, envelope])
} else if (envelope.type === 'message_update' && envelope.room_id === room.id) { } else if (envelope.type === 'message_update' && envelope.room_id === room.id) {
setHistory((prev) => setHistory((prev) =>
@@ -77,7 +98,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
setWsError(envelope.detail) setWsError(envelope.detail)
} }
}), }),
[socket, room.id], [socket, room.id, refreshHistory],
) )
const connected = socket.connected const connected = socket.connected
+53 -19
View File
@@ -20,7 +20,23 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
const onUnauthenticatedRef = useRef(onUnauthenticated) const onUnauthenticatedRef = useRef(onUnauthenticated)
onUnauthenticatedRef.current = onUnauthenticated onUnauthenticatedRef.current = onUnauthenticated
const subscribersRef = useRef(new Set<(envelope: ServerEnvelope) => void>()) const subscribersRef = useRef(new Set<(envelope: ServerEnvelope) => void>())
const joinedRoomsRef = useRef(new Set<string>()) // Rooms the app *wants* joined (set via joinRoom/leaveRoom) -- distinct
// from whether the server currently has this connection joined, which is
// additionally gated on document visibility below. A backgrounded tab
// stays technically connected but tells the server "leave" for every
// desired room, so the server's existing offline-push logic (which keys
// off room presence, not raw connection state) correctly treats a
// 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 sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
const ws = socketRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, room_id: roomId }))
}
}, [])
useEffect(() => { useEffect(() => {
let stopped = false let stopped = false
@@ -43,9 +59,14 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
reconnectDelay = RECONNECT_BASE_DELAY_MS reconnectDelay = RECONNECT_BASE_DELAY_MS
setConnected(true) setConnected(true)
// Re-join whatever rooms were joined before a reconnect -- the // Re-join whatever rooms were joined before a reconnect -- the
// server has no memory of a dropped connection's prior state. // server has no memory of a dropped connection's prior state. Only
for (const roomId of joinedRoomsRef.current) { // while visible: reconnecting from a backgrounded tab should stay
ws.send(JSON.stringify({ type: 'join', room_id: roomId })) // "left" for the same reason backgrounding leaves in the first
// place (see desiredRoomsRef's comment above).
if (isVisibleRef.current) {
for (const roomId of desiredRoomsRef.current) {
sendRoomFrame('join', roomId)
}
} }
} }
@@ -89,7 +110,20 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
socketRef.current?.close() socketRef.current?.close()
socketRef.current = null socketRef.current = null
} }
}, []) }, [sendRoomFrame])
useEffect(() => {
function handleVisibilityChange() {
const visible = document.visibilityState === 'visible'
if (visible === isVisibleRef.current) return
isVisibleRef.current = visible
for (const roomId of desiredRoomsRef.current) {
sendRoomFrame(visible ? 'join' : 'leave', roomId)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [sendRoomFrame])
const subscribe = useCallback((handler: (envelope: ServerEnvelope) => void) => { const subscribe = useCallback((handler: (envelope: ServerEnvelope) => void) => {
subscribersRef.current.add(handler) subscribersRef.current.add(handler)
@@ -98,21 +132,21 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
} }
}, []) }, [])
const joinRoom = useCallback((roomId: string) => { const joinRoom = useCallback(
joinedRoomsRef.current.add(roomId) (roomId: string) => {
const ws = socketRef.current desiredRoomsRef.current.add(roomId)
if (ws && ws.readyState === WebSocket.OPEN) { if (isVisibleRef.current) sendRoomFrame('join', roomId)
ws.send(JSON.stringify({ type: 'join', room_id: roomId })) },
} [sendRoomFrame],
}, []) )
const leaveRoom = useCallback((roomId: string) => { const leaveRoom = useCallback(
joinedRoomsRef.current.delete(roomId) (roomId: string) => {
const ws = socketRef.current desiredRoomsRef.current.delete(roomId)
if (ws && ws.readyState === WebSocket.OPEN) { if (isVisibleRef.current) sendRoomFrame('leave', roomId)
ws.send(JSON.stringify({ type: 'leave', room_id: roomId })) },
} [sendRoomFrame],
}, []) )
const send = useCallback((roomId: string, content: string, imageId?: string, fileId?: string) => { const send = useCallback((roomId: string, content: string, imageId?: string, fileId?: string) => {
const ws = socketRef.current const ws = socketRef.current