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:
@@ -85,11 +85,13 @@ async def update_profile(
|
||||
current_user.display_name = display_name or None
|
||||
if "theme" in updates:
|
||||
current_user.theme = updates["theme"]
|
||||
if "appear_offline" in updates:
|
||||
current_user.appear_offline = updates["appear_offline"]
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
# theme is private to this user, not shown to anyone else -- only
|
||||
# broadcast when something other members would actually see changed.
|
||||
if "display_name" in updates:
|
||||
if "display_name" in updates or "appear_offline" in updates:
|
||||
await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
|
||||
return current_user
|
||||
|
||||
|
||||
@@ -201,14 +201,24 @@ async def leave_room_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Not a member of this room")
|
||||
|
||||
|
||||
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
||||
# appear_offline always wins, regardless of actual connection -- that's
|
||||
# the whole point of the override (lurking in a room undetected).
|
||||
return "offline" if user.appear_offline or user.id not in online_ids else "online"
|
||||
|
||||
|
||||
@router.get("/{room_id}/members", response_model=list[RoomMemberRead])
|
||||
async def list_room_members_endpoint(
|
||||
request: Request,
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
memberships = await list_room_members(db, room_id)
|
||||
online_ids = await request.app.state.global_presence.online_user_ids(
|
||||
[m.user_id for m in memberships]
|
||||
)
|
||||
return [
|
||||
RoomMemberRead(
|
||||
user_id=m.user_id,
|
||||
@@ -217,6 +227,7 @@ async def list_room_members_endpoint(
|
||||
avatar_filename=m.user.avatar_filename,
|
||||
role=m.role,
|
||||
joined_at=m.joined_at,
|
||||
status=_member_status(m.user, online_ids),
|
||||
)
|
||||
for m in memberships
|
||||
]
|
||||
@@ -244,6 +255,7 @@ async def remove_member_endpoint(
|
||||
|
||||
@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead)
|
||||
async def change_member_role_endpoint(
|
||||
request: Request,
|
||||
room_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: RoomMemberRoleUpdate,
|
||||
@@ -259,6 +271,7 @@ async def change_member_role_endpoint(
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Use transfer-ownership to change the room owner"
|
||||
)
|
||||
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
||||
return RoomMemberRead(
|
||||
user_id=membership.user_id,
|
||||
username=membership.user.username,
|
||||
@@ -266,6 +279,7 @@ async def change_member_role_endpoint(
|
||||
avatar_filename=membership.user.avatar_filename,
|
||||
role=membership.role,
|
||||
joined_at=membership.joined_at,
|
||||
status=_member_status(membership.user, online_ids),
|
||||
)
|
||||
|
||||
|
||||
@@ -468,6 +482,7 @@ async def add_member_endpoint(
|
||||
except AlreadyMemberError:
|
||||
raise HTTPException(status_code=409, detail="That user is already a member")
|
||||
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
|
||||
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
||||
return RoomMemberRead(
|
||||
user_id=membership.user_id,
|
||||
username=membership.user.username,
|
||||
@@ -475,6 +490,7 @@ async def add_member_endpoint(
|
||||
avatar_filename=membership.user.avatar_filename,
|
||||
role=membership.role,
|
||||
joined_at=membership.joined_at,
|
||||
status=_member_status(membership.user, online_ids),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user