Private
Public Access
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:
+31
-1
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user