Private
Public Access
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>
69 lines
2.5 KiB
Python
69 lines
2.5 KiB
Python
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.dependencies import get_current_user
|
|
from app.models import User
|
|
from app.schemas.user import UserDirectoryRead
|
|
from app.storage import UPLOADS_DIR
|
|
|
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
|
|
|
|
|
@router.get("", response_model=list[UserDirectoryRead])
|
|
async def list_users_directory_endpoint(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(User)
|
|
.where(User.is_active.is_(True), User.is_bot.is_(False))
|
|
.order_by(User.username)
|
|
)
|
|
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,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
user = await db.get(User, user_id)
|
|
if user is None or not user.avatar_filename:
|
|
raise HTTPException(status_code=404, detail="No avatar set")
|
|
return FileResponse(
|
|
UPLOADS_DIR / user.avatar_filename,
|
|
media_type=user.avatar_content_type,
|
|
# Unlike message images (content-addressed, immutable once posted),
|
|
# an avatar URL is identity-addressed and its content can change on
|
|
# re-upload -- a short cache instead of `immutable` so a stale copy
|
|
# doesn't linger. Not room-membership-gated: avatar visibility
|
|
# matches username visibility (anyone logged in), unlike room-scoped
|
|
# message content.
|
|
headers={"Cache-Control": "private, max-age=300"},
|
|
)
|