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