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>
53 lines
1.6 KiB
Python
53 lines
1.6 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
username: str = Field(min_length=3, max_length=50)
|
|
email: EmailStr
|
|
password: str = Field(min_length=8, max_length=200)
|
|
|
|
|
|
class UserRead(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
username: str
|
|
email: EmailStr
|
|
is_bot: bool
|
|
is_site_admin: bool
|
|
display_name: str | None
|
|
theme: str | None
|
|
avatar_filename: str | None
|
|
appear_offline: bool
|
|
created_at: datetime
|
|
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
# Each field is independently optional-and-settable -- the router only
|
|
# applies keys actually present in the request body
|
|
# (model_dump(exclude_unset=True)), so a call that only wants to change
|
|
# the theme doesn't clobber display_name (or appear_offline) back to
|
|
# their defaults, and vice versa.
|
|
display_name: str | None = Field(default=None, max_length=50)
|
|
# Kept in sync with frontend/src/styles/themes.css's theme blocks.
|
|
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
|
|
appear_offline: bool | None = Field(default=None)
|
|
|
|
|
|
class UserDirectoryRead(BaseModel):
|
|
"""Lightweight entry for user-picker UIs (room invites, admin ownership
|
|
transfer) -- same visibility level as an avatar: any authenticated user
|
|
can see this much about anyone (excludes bots, which aren't invited
|
|
through these flows)."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
username: str
|
|
display_name: str | None
|
|
avatar_filename: str | None
|