Private
Public Access
Add presence indicators and a manual "appear offline" override (#36)
Every avatar in the app (chat messages, room member list, your own avatar in the top bar/profile, the admin user list, the room-invite search) now shows a green/red presence dot. Also adds a global "Appear offline" toggle in the account menu, letting a user lurk in a room undetected -- it overrides the real connection state everywhere, not per-room. Backend: new GlobalPresence (backend/app/ws/global_presence.py), a cross-instance Redis-backed connection tracker parallel to the existing per-room Presence, incremented/decremented on WS connect/ disconnect. A new users.appear_offline column (migration f0f6e494454a) always wins over actual connection state when computing displayed status. RoomMemberRead gained a computed `status` field; add_member/change_member_role/list_room_members all compute it via a shared _member_status() helper. Connect/disconnect and profile updates (display_name, avatar, appear_offline) all broadcast member_updated to every room the user belongs to, reusing the broadcast infrastructure from the earlier avatar-staleness fix, so chat surfaces update live with no new WS envelope type needed. A new GET /api/users/online gives the admin list and user-search a snapshot (deliberately not live -- see backend/app/routers/users.py) for surfaces where "accurate as of page load" is good enough. Frontend: UserAvatar renders an optional status dot; every call site threads status/appear_offline through from whichever data source it already has (room members, the current user, or the new online-ids snapshot for admin/search). 4 new backend tests (backend/tests/test_presence.py); existing broadcast-adjacent WS tests updated to tolerate the new member_updated noise on connect. Verified end-to-end in the browser with two real users: presence dot flips live on connect/disconnect via the existing room-broadcast channel, and the lurk toggle correctly forces offline while still connected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,16 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
return msg
|
||||
|
||||
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -61,7 +71,7 @@ def test_message_fans_out_across_instances(ws_client_factory):
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "message"
|
||||
|
||||
received = bob_ws.receive_json()
|
||||
received = _recv(bob_ws)
|
||||
assert received["type"] == "message"
|
||||
assert received["content"] == "hi from instance 1"
|
||||
assert received["username"] == alice["username"]
|
||||
@@ -101,7 +111,7 @@ def test_presence_is_shared_across_instances(ws_client_factory, monkeypatch):
|
||||
# get the broadcast via Redis, not a push notification. If
|
||||
# presence were still process-local (pre-phase-5 behavior) he'd
|
||||
# look offline to instance1 and get a redundant push.
|
||||
assert bob_ws.receive_json()["type"] == "message"
|
||||
assert _recv(bob_ws)["type"] == "message"
|
||||
|
||||
# Sync barrier: the handler processes frames strictly
|
||||
# sequentially, so a second (idempotent) join only acks once the
|
||||
|
||||
@@ -8,6 +8,16 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
return msg
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
@@ -113,7 +123,7 @@ def test_edit_fans_out_across_instances(ws_client_factory):
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||
message = alice_ws.receive_json()
|
||||
assert bob_ws.receive_json()["type"] == "message"
|
||||
assert _recv(bob_ws)["type"] == "message"
|
||||
|
||||
alice_ws.send_json(
|
||||
{
|
||||
@@ -125,6 +135,6 @@ def test_edit_fans_out_across_instances(ws_client_factory):
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "message_update"
|
||||
|
||||
update = bob_ws.receive_json()
|
||||
update = _recv(bob_ws)
|
||||
assert update["type"] == "message_update"
|
||||
assert update["content"] == "hi, edited"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import uuid
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
# Disconnect-side cleanup (GlobalPresence.disconnect, the offline broadcast)
|
||||
# is deliberately not exercised end-to-end here via a `with websocket_
|
||||
# connect(...)` block closing: Starlette's TestClient tears down a
|
||||
# websocket session by cancelling the server-side handler's task (confirmed
|
||||
# via asyncio.CancelledError while investigating a hang here), not by
|
||||
# delivering a real ASGI "websocket.disconnect" message the way an actual
|
||||
# client going away does -- so a `finally` block's own `await` calls can be
|
||||
# interrupted mid-cleanup in tests without that ever happening in
|
||||
# production. No existing test in this suite exercises presence.leave()
|
||||
# post-disconnect either, for the same reason. The connect-side behavior
|
||||
# below (the half that's actually reliably testable) is what matters most:
|
||||
# it proves the online transition and its broadcast work correctly.
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_member_status_reflects_connection(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
|
||||
assert members[0]["status"] == "offline"
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
|
||||
assert members[0]["status"] == "online"
|
||||
|
||||
|
||||
def test_connect_broadcasts_presence_to_shared_room(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat"):
|
||||
# bob connecting is a genuine offline->online transition for
|
||||
# him -- alice, already joined, should hear about it even
|
||||
# though she never sent anything and bob never joined a room.
|
||||
assert alice_ws.receive_json() == {
|
||||
"type": "member_updated",
|
||||
"room_id": room["id"],
|
||||
"user_id": bob["id"],
|
||||
}
|
||||
|
||||
|
||||
def test_appear_offline_overrides_actual_connection(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
|
||||
assert members[0]["status"] == "online"
|
||||
|
||||
resp = ws_client.patch("/api/auth/me", json={"appear_offline": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["appear_offline"] is True
|
||||
|
||||
# alice is joined to her own room, so the broadcast her own change
|
||||
# triggers reaches her own socket.
|
||||
assert ws.receive_json() == {"type": "member_updated", "room_id": room["id"], "user_id": alice["id"]}
|
||||
|
||||
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
|
||||
assert members[0]["status"] == "offline"
|
||||
|
||||
resp = ws_client.patch("/api/auth/me", json={"appear_offline": False})
|
||||
assert resp.status_code == 200
|
||||
assert ws.receive_json()["type"] == "member_updated"
|
||||
|
||||
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
|
||||
assert members[0]["status"] == "online"
|
||||
|
||||
|
||||
def test_online_users_endpoint_respects_appear_offline(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat"):
|
||||
online = ws_client.get("/api/users/online").json()
|
||||
assert alice["id"] in online
|
||||
assert bob["id"] not in online
|
||||
|
||||
resp = ws_client.patch("/api/auth/me", json={"appear_offline": True})
|
||||
assert resp.status_code == 200
|
||||
|
||||
online = ws_client.get("/api/users/online").json()
|
||||
assert alice["id"] not in online
|
||||
|
||||
online = ws_client.get("/api/users/online").json()
|
||||
assert alice["id"] not in online
|
||||
@@ -97,6 +97,16 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
return msg
|
||||
|
||||
|
||||
def _fetch_subscriptions(ws_client, user_id: str) -> list[PushSubscription]:
|
||||
async def _query():
|
||||
async with ws_client.session_factory() as session:
|
||||
@@ -172,7 +182,7 @@ def test_ws_message_no_push_when_member_connected(ws_client, monkeypatch):
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||
assert alice_ws.receive_json()["type"] == "message"
|
||||
# bob is connected too -- he should get the broadcast, not a push
|
||||
assert bob_ws.receive_json()["type"] == "message"
|
||||
assert _recv(bob_ws)["type"] == "message"
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
return msg
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
@@ -121,8 +131,8 @@ def test_reaction_broadcasts_to_other_room_members(ws_client):
|
||||
alice_ws.send_json(
|
||||
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🎉"}
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "reaction_update"
|
||||
update = bob_ws.receive_json()
|
||||
assert _recv(alice_ws)["type"] == "reaction_update"
|
||||
update = _recv(bob_ws)
|
||||
assert update["type"] == "reaction_update"
|
||||
assert update["reactions"][0]["emoji"] == "🎉"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user