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
+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)