Fix desktop messages not appearing live until refocus (#59)

Desktop mode's focus gating (from #49) made losing OS focus send "leave"
for every open room, which stopped live message delivery to that room,
not just notification eligibility -- so a message wouldn't render until
the room was manually left and rejoined. Room join/leave is now gated on
visibility alone, matching the browser; notification eligibility gets its
own separate signal (a "focus"/"blur" WS frame tracked by a new
Redis-backed FocusPresence), so a connected-but-unfocused desktop member
still gets notified without losing live delivery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:08:04 -06:00
co-authored by Claude Sonnet 5
parent 1d9fe25410
commit ed88eb0205
7 changed files with 250 additions and 49 deletions
+2
View File
@@ -27,6 +27,7 @@ from app.routers import (
from app.ws.broadcaster import Broadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence
@@ -82,6 +83,7 @@ def create_app() -> FastAPI:
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
app.state.presence = Presence(app.state.redis)
app.state.global_presence = GlobalPresence(app.state.redis)
app.state.focus_presence = FocusPresence(app.state.redis)
app.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager)
app.include_router(health.router)
+2 -1
View File
@@ -25,4 +25,5 @@ async def incoming_webhook_endpoint(
broadcaster = request.app.state.broadcaster
presence = request.app.state.presence
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
focus_presence = request.app.state.focus_presence
await broadcast_new_message(db, broadcaster, presence, focus_presence, room.id, message, sender)
+27 -10
View File
@@ -10,6 +10,7 @@ from app.services.link_preview_service import fetch_and_broadcast_link_preview
from app.services.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event
from app.ws.broadcaster import Broadcaster
from app.ws.focus_presence import FocusPresence
from app.ws.presence import Presence
@@ -17,6 +18,7 @@ async def _notify_offline_members(
db: AsyncSession,
broadcaster: Broadcaster,
presence: Presence,
focus_presence: FocusPresence,
room_id: uuid.UUID,
sender: User,
message: Message,
@@ -25,12 +27,28 @@ async def _notify_offline_members(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
)
member_ids = {row[0] for row in result.all()}
connected_ids = await presence.connected_user_ids(room_id)
# Subtract the sender explicitly rather than relying on them being
# "connected" (true for the WS path, since they just sent this over an
# active connection -- not true for the incoming-webhook REST path,
# which has no WS connection for the attributed sender at all).
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
if not offline_ids:
offline_ids = member_ids - connected_ids - {sender.id}
# #59: a desktop-mode member can be *connected* to this room's channel
# (it's open on screen, live messages are rendering) while their window
# sits unfocused behind something else -- still exactly the situation a
# desktop notification should fire for, same as #49's original intent.
# This used to be handled by the client faking "offline" (leaving the
# room's channel on blur), which also silently stopped live delivery to
# that room; FocusPresence is a separate signal so notification
# eligibility no longer has to ride on room-connection state at all.
connected_but_unfocused_ids = {
user_id
for user_id in connected_ids - {sender.id}
if await focus_presence.is_unfocused(user_id)
}
notify_ids = offline_ids | connected_but_unfocused_ids
if not notify_ids:
return
result = await db.execute(
@@ -38,12 +56,10 @@ async def _notify_offline_members(
)
mentioned_ids = {row[0] for row in result.all()}
# This is also exactly the right audience for "give this room an unread
# dot": presence.connected_user_ids(room_id) means "has this room's
# channel joined right now" -- which the client only does while the tab
# is genuinely foregrounded (see useChatSocket.ts's visibility-gated
# join/leave), so a backgrounded-but-open room correctly lands here too,
# not just rooms that aren't open at all.
# Unread-dot audience stays exactly offline_ids, not notify_ids: a
# connected-but-unfocused member still has the room open and rendering
# on screen right now, so it isn't actually "unread" for them the way a
# room they haven't got open at all is.
for user_id in offline_ids:
await broadcaster.publish_to_user(
user_id,
@@ -56,7 +72,7 @@ async def _notify_offline_members(
room = await db.get(Room, room_id)
title = f"#{room.name}" if room else "New message"
for user_id in offline_ids:
for user_id in notify_ids:
mentioned = user_id in mentioned_ids
if message.content:
prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: "
@@ -131,6 +147,7 @@ async def broadcast_new_message(
db: AsyncSession,
broadcaster: Broadcaster,
presence: Presence,
focus_presence: FocusPresence,
room_id: uuid.UUID,
message: Message,
sender: User,
@@ -161,7 +178,7 @@ async def broadcast_new_message(
await broadcaster.publish_to_user(
unhidden_user_id, {"type": "room_added", "room_id": str(room_id)}
)
await _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
await dispatch_event(db, "message.created", room_id, payload)
_maybe_fetch_link_preview(broadcaster, room_id, message)
+31 -1
View File
@@ -36,6 +36,7 @@ class ClientEnvelope(BaseModel):
file_id: uuid.UUID | None = None
message_id: uuid.UUID | None = None
emoji: str | None = None
focused: bool | None = None
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
@@ -79,8 +80,14 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager = websocket.app.state.connection_manager
presence = websocket.app.state.presence
global_presence = websocket.app.state.global_presence
focus_presence = websocket.app.state.focus_presence
broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set()
# Tracks this connection's last-reported focus state (see the "focus"
# envelope below) so the disconnect cleanup can release FocusPresence's
# refcount if the socket closes while still blurred -- mirroring how
# joined_rooms tracks per-connection room membership for its own cleanup.
is_blurred = False
manager.register_user(user.id, websocket)
# Only broadcast on a genuine offline->online transition (this user's
# first open connection), not for every extra tab -- broadcast_member_
@@ -133,6 +140,25 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await presence.leave(envelope.room_id, user.id)
joined_rooms.discard(envelope.room_id)
elif envelope.type == "focus":
# Sent only by the desktop client (#59), independent of
# room join/leave -- see FocusPresence's docstring for
# why widening desktop_notification eligibility this
# way no longer needs to touch live room delivery at
# all, unlike the "leave the room's channel on blur"
# approach this replaced.
if envelope.focused is None:
await websocket.send_json({"type": "error", "detail": "focused required"})
continue
if envelope.focused:
if is_blurred:
await focus_presence.mark_focused(user.id)
is_blurred = False
else:
if not is_blurred:
await focus_presence.mark_blurred(user.id)
is_blurred = True
elif envelope.type == "message":
if envelope.room_id is None or (
not envelope.content
@@ -185,7 +211,9 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
# incoming-webhook path also calls it, and a webhook's
# attributed sender may not actually be watching.
await mark_room_read(db, envelope.room_id, user.id)
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
await broadcast_new_message(
db, broadcaster, presence, focus_presence, envelope.room_id, message, user
)
elif envelope.type == "edit":
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
@@ -268,5 +296,7 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager.unregister_user(user.id, websocket)
for room_id in joined_rooms:
await presence.leave(room_id, user.id)
if is_blurred:
await focus_presence.mark_focused(user.id)
if await global_presence.disconnect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
+45
View File
@@ -0,0 +1,45 @@
import uuid
from redis.asyncio import Redis
class FocusPresence:
"""Cross-instance "is this desktop-mode user's window currently
unfocused," used only to widen desktop_notification/push eligibility
beyond plain room-connection state (#59). A Redis hash (field =
user_id, value = refcount of that user's currently-blurred desktop
connections), parallel to Presence/GlobalPresence.
Absence from this hash is the default and means "focused." That default
is also exactly right for every browser-tab connection: only the
desktop client ever sends focus/blur frames at all (see chat.py's
"focus" envelope handling), so a browser user never appears here --
their attention is already fully captured by Presence's room-connection
state, which stays visibility-gated with no separate focus signal.
Refcounted for the same multi-connection reason as Presence/
GlobalPresence, with the same known simplification: two desktop windows
for one user, one focused and one blurred, count as "unfocused" here
(refcount > 0) even though the user does have attention somewhere. That
errs toward notifying rather than silently missing one, which is the
safer failure mode for a notification.
"""
def __init__(self, redis: Redis) -> None:
self._redis = redis
def _key(self) -> str:
return "presence:unfocused"
async def mark_blurred(self, user_id: uuid.UUID) -> None:
await self._redis.hincrby(self._key(), str(user_id), 1)
async def mark_focused(self, user_id: uuid.UUID) -> None:
key = self._key()
field = str(user_id)
remaining = await self._redis.hincrby(key, field, -1)
if remaining <= 0:
await self._redis.hdel(key, field)
async def is_unfocused(self, user_id: uuid.UUID) -> bool:
return await self._redis.hexists(self._key(), str(user_id))
@@ -128,6 +128,87 @@ def test_desktop_notification_not_sent_to_room_member_who_is_present(ws_client_f
assert live_message["type"] == "message"
def test_desktop_notification_sent_to_connected_but_blurred_member(ws_client_factory):
# #59: bob keeps the room's channel joined (so live delivery to a room
# actually open on screen never stops) but reports his desktop window
# as unfocused via a "focus" frame -- the whole point of this fix is
# that notification eligibility no longer needs the client to fake
# "offline" by leaving the room's channel, which used to also break
# live delivery until the room was manually left and rejoined.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
bob_ws.send_json({"type": "focus", "focused": False})
# Sync barrier -- see test_mentions.py's identical pattern: a
# second (idempotent) join only acks once the prior "focus"
# frame's own handling (and commit) has completed.
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
message = _send_and_sync(alice_ws, room["id"], "you awake?")
assert message["type"] == "message"
# Live delivery still works -- bob's room channel was never left.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
assert live_message["content"] == "you awake?"
# ...and he's still notified, despite being "connected" to the room.
desktop_note = _recv(bob_ws)
assert desktop_note == {
"type": "desktop_notification",
"id": message["id"],
"room_id": room["id"],
"title": f"#{room['name']}",
"body": f"{alice['username']}: you awake?",
}
def test_desktop_notification_not_sent_after_refocus(ws_client_factory):
# Proves the "focus" signal is a live toggle, not one-way -- blurring
# and then refocusing before the message arrives must fully cancel out.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws:
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
bob_ws.send_json({"type": "focus", "focused": False})
bob_ws.send_json({"type": "focus", "focused": True})
bob_ws.send_json({"type": "join", "room_id": room["id"]})
assert bob_ws.receive_json()["type"] == "joined"
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "hello again")
# Refocused before the message arrived -- only the live broadcast,
# same as test_desktop_notification_not_sent_to_room_member_who_is_present.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
def test_desktop_notification_not_sent_to_non_member(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
+62 -37
View File
@@ -10,20 +10,23 @@ 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())
// Room join/leave (live message delivery) depends only on visibility --
// "not minimized/hidden" -- exactly like a browser tab, in every mode.
//
// #49 originally had desktop mode additionally require document.hasFocus()
// here, on the theory that losing OS focus should count as "not present"
// the same way backgrounding a browser tab does. #59: that conflated two
// separate concerns onto one signal -- losing focus made the desktop client
// send "leave" for every open room, which stopped *live delivery* to a room
// still fully visible on screen, not just notification eligibility. A
// message wouldn't appear until the room was manually left and rejoined
// (e.g. switching rooms and back), which is what actually got reported.
// Focus now drives its own separate signal (see the "focus" WS frame below
// and FocusPresence server-side) instead of gating room membership at all.
function isVisible(): boolean {
return document.visibilityState === 'visible'
}
// One connection per authenticated session, established as soon as the app
@@ -47,7 +50,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(isPresent())
const isVisibleRef = useRef(isVisible())
const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => {
const ws = socketRef.current
@@ -56,6 +59,18 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
}
}, [])
// #59: reports this desktop window's focus state as its own signal,
// completely separate from room join/leave above -- see FocusPresence
// server-side. No-op (and never called) outside desktop mode, matching
// how the server only ever expects "focus" frames from the desktop
// client at all.
const sendFocusFrame = useCallback(() => {
const ws = socketRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'focus', focused: document.hasFocus() }))
}
}, [])
useEffect(() => {
let stopped = false
let reconnectDelay = RECONNECT_BASE_DELAY_MS
@@ -91,12 +106,18 @@ 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 = isPresent()
isVisibleRef.current = isVisible()
if (isVisibleRef.current) {
for (const roomId of desiredRoomsRef.current) {
sendRoomFrame('join', roomId)
}
}
// The server's FocusPresence state for this user doesn't survive a
// dropped connection either (see chat.py's disconnect cleanup) --
// report the current value fresh on every (re)connect, not just on
// the next focus/blur transition, so a reconnect while unfocused
// (e.g. after a deploy) doesn't leave the server assuming focused.
if (desktopMode) sendFocusFrame()
}
ws.onmessage = (event) => {
@@ -139,35 +160,39 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
socketRef.current?.close()
socketRef.current = null
}
}, [sendRoomFrame])
}, [sendRoomFrame, sendFocusFrame])
useEffect(() => {
function handlePresenceChange() {
const present = isPresent()
if (present === isVisibleRef.current) return
isVisibleRef.current = present
function handleVisibilityChange() {
const visible = isVisible()
if (visible === isVisibleRef.current) return
isVisibleRef.current = visible
for (const roomId of desiredRoomsRef.current) {
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)
sendRoomFrame(visible ? 'join' : 'leave', roomId)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [sendRoomFrame])
// #59: focus/blur reporting, entirely separate from the visibility effect
// above -- losing OS focus no longer touches room membership at all, just
// this signal (consumed server-side by FocusPresence to widen desktop-
// notification eligibility). Only in desktop mode: on a browser tab,
// focus/blur fire on every click into/out of the page (e.g. opening
// devtools), a far noisier signal than intended, and browser tabs don't
// need it anyway -- visibility alone already matches pre-#49 behavior
// there.
useEffect(() => {
if (!desktopMode) return
window.addEventListener('focus', sendFocusFrame)
window.addEventListener('blur', sendFocusFrame)
return () => {
window.removeEventListener('focus', sendFocusFrame)
window.removeEventListener('blur', sendFocusFrame)
}
}, [sendFocusFrame])
const subscribe = useCallback((handler: (envelope: ServerEnvelope) => void) => {
subscribersRef.current.add(handler)
return () => {
@@ -193,7 +218,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 = isPresent()
isVisibleRef.current = isVisible()
if (isVisibleRef.current) sendRoomFrame('join', roomId)
},
[sendRoomFrame],