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
@@ -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()