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
+20 -1
View File
@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,25 @@ async def list_users_directory_endpoint(
return list(result.scalars().all())
@router.get("/online", response_model=list[uuid.UUID])
async def list_online_users_endpoint(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[uuid.UUID]:
"""A snapshot, not a live feed -- deliberately simpler than the presence
dot in chat (which updates live via the existing room-broadcast
machinery). Used by surfaces where "accurate as of page load" is good
enough: the admin user list and the room-invite user search."""
raw_online_ids = await request.app.state.global_presence.all_online_user_ids()
if not raw_online_ids:
return []
result = await db.execute(
select(User.id).where(User.id.in_(raw_online_ids), User.appear_offline.is_(False))
)
return [row[0] for row in result.all()]
@router.get("/{user_id}/avatar")
async def get_user_avatar_endpoint(
user_id: uuid.UUID,