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:
2026-08-16 15:39:56 -06:00
co-authored by Claude Sonnet 5
parent 1c2d2e91c1
commit 7ef6cfca65
28 changed files with 469 additions and 29 deletions
+11
View File
@@ -9,6 +9,7 @@ from app.database import get_db
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
from app.services.bot_service import resolve_token
from app.services.message_events import (
broadcast_member_updated,
broadcast_message_update,
broadcast_new_message,
broadcast_reaction_update,
@@ -76,9 +77,17 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await websocket.accept()
manager = websocket.app.state.connection_manager
presence = websocket.app.state.presence
global_presence = websocket.app.state.global_presence
broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set()
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_
# updated tells every room this user's in to refresh, which would be
# wasted churn on a transition that didn't actually change anything
# visible.
if await global_presence.connect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
try:
while True:
@@ -229,3 +238,5 @@ 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 await global_presence.disconnect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
+51
View File
@@ -0,0 +1,51 @@
import uuid
from redis.asyncio import Redis
class GlobalPresence:
"""Cross-instance "does this user have the app open at all right now,"
independent of which (if any) room they currently have open -- backing
the online/offline presence dot shown wherever a user's avatar renders.
A single Redis hash (field = user_id, value = connection refcount),
parallel to but separate from Presence's per-room hashes.
Refcounted for the same reason as Presence: multiple tabs/instances for
one user shouldn't flip them offline until the last connection closes.
"""
def __init__(self, redis: Redis) -> None:
self._redis = redis
def _key(self) -> str:
return "presence:global"
async def connect(self, user_id: uuid.UUID) -> bool:
"""Returns True iff this was the user's first open connection --
a genuine offline->online transition worth telling anyone about."""
count = await self._redis.hincrby(self._key(), str(user_id), 1)
return count == 1
async def disconnect(self, user_id: uuid.UUID) -> bool:
"""Returns True iff this was the user's last open connection -- a
genuine online->offline transition."""
key = self._key()
field = str(user_id)
remaining = await self._redis.hincrby(key, field, -1)
if remaining <= 0:
await self._redis.hdel(key, field)
return True
return False
async def is_online(self, user_id: uuid.UUID) -> bool:
return await self._redis.hexists(self._key(), str(user_id))
async def online_user_ids(self, user_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not user_ids:
return set()
values = await self._redis.hmget(self._key(), [str(u) for u in user_ids])
return {uid for uid, v in zip(user_ids, values) if v is not None}
async def all_online_user_ids(self) -> set[uuid.UUID]:
fields = await self._redis.hkeys(self._key())
return {uuid.UUID(f) for f in fields}