Files
ds-chat/backend/app/schemas/user.py
T
ksmithandClaude Sonnet 5 7ef6cfca65 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>
2026-08-16 15:39:56 -06:00

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