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
@@ -0,0 +1,32 @@
"""user appear_offline presence override
Revision ID: f0f6e494454a
Revises: e849b2efb79b
Create Date: 2026-08-16 15:04:50.204291
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f0f6e494454a'
down_revision: Union[str, Sequence[str], None] = 'e849b2efb79b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('appear_offline', sa.Boolean(), server_default='false', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'appear_offline')
# ### end Alembic commands ###
+2
View File
@@ -15,6 +15,7 @@ from app.routers import admin, auth, bots, health, push, rooms, signup, uploads,
from app.ws.broadcaster import Broadcaster from app.ws.broadcaster import Broadcaster
from app.ws.chat import router as ws_router from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence from app.ws.presence import Presence
# backend/app/main.py -> backend/ -> repo root -- matches both the local # backend/app/main.py -> backend/ -> repo root -- matches both the local
@@ -68,6 +69,7 @@ def create_app() -> FastAPI:
app.state.connection_manager = ConnectionManager() app.state.connection_manager = ConnectionManager()
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True) app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
app.state.presence = Presence(app.state.redis) app.state.presence = Presence(app.state.redis)
app.state.global_presence = GlobalPresence(app.state.redis)
app.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager) app.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager)
app.include_router(health.router) app.include_router(health.router)
+4
View File
@@ -21,6 +21,10 @@ class User(Base):
theme: Mapped[str | None] = mapped_column(String(20)) theme: Mapped[str | None] = mapped_column(String(20))
avatar_filename: Mapped[str | None] = mapped_column(String(64)) avatar_filename: Mapped[str | None] = mapped_column(String(64))
avatar_content_type: Mapped[str | None] = mapped_column(String(50)) avatar_content_type: Mapped[str | None] = mapped_column(String(50))
# Manual override for the presence indicator -- when set, this user
# always shows as offline to everyone regardless of actual connection
# state (a "lurk" mode), independent of any individual room.
appear_offline: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
+3 -1
View File
@@ -85,11 +85,13 @@ async def update_profile(
current_user.display_name = display_name or None current_user.display_name = display_name or None
if "theme" in updates: if "theme" in updates:
current_user.theme = updates["theme"] current_user.theme = updates["theme"]
if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"]
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
# theme is private to this user, not shown to anyone else -- only # theme is private to this user, not shown to anyone else -- only
# broadcast when something other members would actually see changed. # 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) await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
return current_user return current_user
+16
View File
@@ -201,14 +201,24 @@ async def leave_room_endpoint(
raise HTTPException(status_code=404, detail="Not a member of this room") 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]) @router.get("/{room_id}/members", response_model=list[RoomMemberRead])
async def list_room_members_endpoint( async def list_room_members_endpoint(
request: Request,
room_id: uuid.UUID, room_id: uuid.UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
await require_room_member(room_id, current_user, db) await require_room_member(room_id, current_user, db)
memberships = await list_room_members(db, room_id) 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 [ return [
RoomMemberRead( RoomMemberRead(
user_id=m.user_id, user_id=m.user_id,
@@ -217,6 +227,7 @@ async def list_room_members_endpoint(
avatar_filename=m.user.avatar_filename, avatar_filename=m.user.avatar_filename,
role=m.role, role=m.role,
joined_at=m.joined_at, joined_at=m.joined_at,
status=_member_status(m.user, online_ids),
) )
for m in memberships for m in memberships
] ]
@@ -244,6 +255,7 @@ async def remove_member_endpoint(
@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead) @router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead)
async def change_member_role_endpoint( async def change_member_role_endpoint(
request: Request,
room_id: uuid.UUID, room_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
data: RoomMemberRoleUpdate, data: RoomMemberRoleUpdate,
@@ -259,6 +271,7 @@ async def change_member_role_endpoint(
raise HTTPException( raise HTTPException(
status_code=400, detail="Use transfer-ownership to change the room owner" 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( return RoomMemberRead(
user_id=membership.user_id, user_id=membership.user_id,
username=membership.user.username, username=membership.user.username,
@@ -266,6 +279,7 @@ async def change_member_role_endpoint(
avatar_filename=membership.user.avatar_filename, avatar_filename=membership.user.avatar_filename,
role=membership.role, role=membership.role,
joined_at=membership.joined_at, joined_at=membership.joined_at,
status=_member_status(membership.user, online_ids),
) )
@@ -468,6 +482,7 @@ async def add_member_endpoint(
except AlreadyMemberError: except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member") raise HTTPException(status_code=409, detail="That user is already a member")
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room) 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( return RoomMemberRead(
user_id=membership.user_id, user_id=membership.user_id,
username=membership.user.username, username=membership.user.username,
@@ -475,6 +490,7 @@ async def add_member_endpoint(
avatar_filename=membership.user.avatar_filename, avatar_filename=membership.user.avatar_filename,
role=membership.role, role=membership.role,
joined_at=membership.joined_at, joined_at=membership.joined_at,
status=_member_status(membership.user, online_ids),
) )
+20 -1
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,25 @@ async def list_users_directory_endpoint(
return list(result.scalars().all()) 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") @router.get("/{user_id}/avatar")
async def get_user_avatar_endpoint( async def get_user_avatar_endpoint(
user_id: uuid.UUID, user_id: uuid.UUID,
+5
View File
@@ -1,5 +1,6 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -43,6 +44,10 @@ class RoomMemberRead(BaseModel):
avatar_filename: str | None avatar_filename: str | None
role: RoomRole role: RoomRole
joined_at: datetime joined_at: datetime
# "offline" whenever the user has set appear_offline, regardless of
# actual connection -- computed by the router (needs GlobalPresence),
# not derivable from the model alone.
status: Literal["online", "offline"]
class RoomMemberAdd(BaseModel): class RoomMemberAdd(BaseModel):
+6 -3
View File
@@ -22,17 +22,20 @@ class UserRead(BaseModel):
display_name: str | None display_name: str | None
theme: str | None theme: str | None
avatar_filename: str | None avatar_filename: str | None
appear_offline: bool
created_at: datetime created_at: datetime
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
# Both fields are independently optional-and-settable -- the router # Each field is independently optional-and-settable -- the router only
# only applies keys actually present in the request body # applies keys actually present in the request body
# (model_dump(exclude_unset=True)), so a call that only wants to change # (model_dump(exclude_unset=True)), so a call that only wants to change
# the theme doesn't clobber display_name back to None, and vice versa. # 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) display_name: str | None = Field(default=None, max_length=50)
# Kept in sync with frontend/src/styles/themes.css's theme blocks. # Kept in sync with frontend/src/styles/themes.css's theme blocks.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None) theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
appear_offline: bool | None = Field(default=None)
class UserDirectoryRead(BaseModel): class UserDirectoryRead(BaseModel):
+11
View File
@@ -9,6 +9,7 @@ from app.database import get_db
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
from app.services.bot_service import resolve_token from app.services.bot_service import resolve_token
from app.services.message_events import ( from app.services.message_events import (
broadcast_member_updated,
broadcast_message_update, broadcast_message_update,
broadcast_new_message, broadcast_new_message,
broadcast_reaction_update, broadcast_reaction_update,
@@ -76,9 +77,17 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await websocket.accept() await websocket.accept()
manager = websocket.app.state.connection_manager manager = websocket.app.state.connection_manager
presence = websocket.app.state.presence presence = websocket.app.state.presence
global_presence = websocket.app.state.global_presence
broadcaster = websocket.app.state.broadcaster broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set() joined_rooms: set[uuid.UUID] = set()
manager.register_user(user.id, websocket) manager.register_user(user.id, websocket)
# Only broadcast on a genuine offline->online transition (this user's
# first open connection), not for every extra tab -- broadcast_member_
# updated tells every room this user's in to refresh, which would be
# wasted churn on a transition that didn't actually change anything
# visible.
if await global_presence.connect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
try: try:
while True: while True:
@@ -229,3 +238,5 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager.unregister_user(user.id, websocket) manager.unregister_user(user.id, websocket)
for room_id in joined_rooms: for room_id in joined_rooms:
await presence.leave(room_id, user.id) await presence.leave(room_id, user.id)
if await global_presence.disconnect(user.id):
await broadcast_member_updated(db, broadcaster, user.id)
+51
View File
@@ -0,0 +1,51 @@
import uuid
from redis.asyncio import Redis
class GlobalPresence:
"""Cross-instance "does this user have the app open at all right now,"
independent of which (if any) room they currently have open -- backing
the online/offline presence dot shown wherever a user's avatar renders.
A single Redis hash (field = user_id, value = connection refcount),
parallel to but separate from Presence's per-room hashes.
Refcounted for the same reason as Presence: multiple tabs/instances for
one user shouldn't flip them offline until the last connection closes.
"""
def __init__(self, redis: Redis) -> None:
self._redis = redis
def _key(self) -> str:
return "presence:global"
async def connect(self, user_id: uuid.UUID) -> bool:
"""Returns True iff this was the user's first open connection --
a genuine offline->online transition worth telling anyone about."""
count = await self._redis.hincrby(self._key(), str(user_id), 1)
return count == 1
async def disconnect(self, user_id: uuid.UUID) -> bool:
"""Returns True iff this was the user's last open connection -- a
genuine online->offline transition."""
key = self._key()
field = str(user_id)
remaining = await self._redis.hincrby(key, field, -1)
if remaining <= 0:
await self._redis.hdel(key, field)
return True
return False
async def is_online(self, user_id: uuid.UUID) -> bool:
return await self._redis.hexists(self._key(), str(user_id))
async def online_user_ids(self, user_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not user_ids:
return set()
values = await self._redis.hmget(self._key(), [str(u) for u in user_ids])
return {uid for uid, v in zip(user_ids, values) if v is not None}
async def all_online_user_ids(self) -> set[uuid.UUID]:
fields = await self._redis.hkeys(self._key())
return {uuid.UUID(f) for f in fields}
+12 -2
View File
@@ -8,6 +8,16 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
return msg
def _fake_send_email(monkeypatch): def _fake_send_email(monkeypatch):
calls = [] calls = []
@@ -61,7 +71,7 @@ def test_message_fans_out_across_instances(ws_client_factory):
) )
assert alice_ws.receive_json()["type"] == "message" assert alice_ws.receive_json()["type"] == "message"
received = bob_ws.receive_json() received = _recv(bob_ws)
assert received["type"] == "message" assert received["type"] == "message"
assert received["content"] == "hi from instance 1" assert received["content"] == "hi from instance 1"
assert received["username"] == alice["username"] assert received["username"] == alice["username"]
@@ -101,7 +111,7 @@ def test_presence_is_shared_across_instances(ws_client_factory, monkeypatch):
# get the broadcast via Redis, not a push notification. If # get the broadcast via Redis, not a push notification. If
# presence were still process-local (pre-phase-5 behavior) he'd # presence were still process-local (pre-phase-5 behavior) he'd
# look offline to instance1 and get a redundant push. # look offline to instance1 and get a redundant push.
assert bob_ws.receive_json()["type"] == "message" assert _recv(bob_ws)["type"] == "message"
# Sync barrier: the handler processes frames strictly # Sync barrier: the handler processes frames strictly
# sequentially, so a second (idempotent) join only acks once the # sequentially, so a second (idempotent) join only acks once the
+12 -2
View File
@@ -8,6 +8,16 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
return msg
def _register_ws(ws_client, username: str) -> dict: def _register_ws(ws_client, username: str) -> dict:
async def _seed(): async def _seed():
async with ws_client.session_factory() as session: async with ws_client.session_factory() as session:
@@ -113,7 +123,7 @@ def test_edit_fans_out_across_instances(ws_client_factory):
assert alice_ws.receive_json()["type"] == "joined" assert alice_ws.receive_json()["type"] == "joined"
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"}) alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
message = alice_ws.receive_json() message = alice_ws.receive_json()
assert bob_ws.receive_json()["type"] == "message" assert _recv(bob_ws)["type"] == "message"
alice_ws.send_json( alice_ws.send_json(
{ {
@@ -125,6 +135,6 @@ def test_edit_fans_out_across_instances(ws_client_factory):
) )
assert alice_ws.receive_json()["type"] == "message_update" assert alice_ws.receive_json()["type"] == "message_update"
update = bob_ws.receive_json() update = _recv(bob_ws)
assert update["type"] == "message_update" assert update["type"] == "message_update"
assert update["content"] == "hi, edited" assert update["content"] == "hi, edited"
+132
View File
@@ -0,0 +1,132 @@
import uuid
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
# Disconnect-side cleanup (GlobalPresence.disconnect, the offline broadcast)
# is deliberately not exercised end-to-end here via a `with websocket_
# connect(...)` block closing: Starlette's TestClient tears down a
# websocket session by cancelling the server-side handler's task (confirmed
# via asyncio.CancelledError while investigating a hang here), not by
# delivering a real ASGI "websocket.disconnect" message the way an actual
# client going away does -- so a `finally` block's own `await` calls can be
# interrupted mid-cleanup in tests without that ever happening in
# production. No existing test in this suite exercises presence.leave()
# post-disconnect either, for the same reason. The connect-side behavior
# below (the half that's actually reliably testable) is what matters most:
# it proves the online transition and its broadcast work correctly.
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_member_status_reflects_connection(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "offline"
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "online"
def test_connect_broadcasts_presence_to_shared_room(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
ws_client.post(
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat"):
# bob connecting is a genuine offline->online transition for
# him -- alice, already joined, should hear about it even
# though she never sent anything and bob never joined a room.
assert alice_ws.receive_json() == {
"type": "member_updated",
"room_id": room["id"],
"user_id": bob["id"],
}
def test_appear_offline_overrides_actual_connection(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "online"
resp = ws_client.patch("/api/auth/me", json={"appear_offline": True})
assert resp.status_code == 200
assert resp.json()["appear_offline"] is True
# alice is joined to her own room, so the broadcast her own change
# triggers reaches her own socket.
assert ws.receive_json() == {"type": "member_updated", "room_id": room["id"], "user_id": alice["id"]}
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "offline"
resp = ws_client.patch("/api/auth/me", json={"appear_offline": False})
assert resp.status_code == 200
assert ws.receive_json()["type"] == "member_updated"
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "online"
def test_online_users_endpoint_respects_appear_offline(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat"):
online = ws_client.get("/api/users/online").json()
assert alice["id"] in online
assert bob["id"] not in online
resp = ws_client.patch("/api/auth/me", json={"appear_offline": True})
assert resp.status_code == 200
online = ws_client.get("/api/users/online").json()
assert alice["id"] not in online
online = ws_client.get("/api/users/online").json()
assert alice["id"] not in online
+11 -1
View File
@@ -97,6 +97,16 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
return msg
def _fetch_subscriptions(ws_client, user_id: str) -> list[PushSubscription]: def _fetch_subscriptions(ws_client, user_id: str) -> list[PushSubscription]:
async def _query(): async def _query():
async with ws_client.session_factory() as session: async with ws_client.session_factory() as session:
@@ -172,7 +182,7 @@ def test_ws_message_no_push_when_member_connected(ws_client, monkeypatch):
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"}) alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
assert alice_ws.receive_json()["type"] == "message" assert alice_ws.receive_json()["type"] == "message"
# bob is connected too -- he should get the broadcast, not a push # bob is connected too -- he should get the broadcast, not a push
assert bob_ws.receive_json()["type"] == "message" assert _recv(bob_ws)["type"] == "message"
assert calls == [] assert calls == []
+12 -2
View File
@@ -9,6 +9,16 @@ def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}" return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _recv(ws) -> dict:
"""Reads the next frame, transparently discarding member_updated
presence-change broadcasts -- another connection in the same room going
online/offline is real, expected noise these tests aren't about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
return msg
def _register_ws(ws_client, username: str) -> dict: def _register_ws(ws_client, username: str) -> dict:
async def _seed(): async def _seed():
async with ws_client.session_factory() as session: async with ws_client.session_factory() as session:
@@ -121,8 +131,8 @@ def test_reaction_broadcasts_to_other_room_members(ws_client):
alice_ws.send_json( alice_ws.send_json(
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🎉"} {"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🎉"}
) )
assert alice_ws.receive_json()["type"] == "reaction_update" assert _recv(alice_ws)["type"] == "reaction_update"
update = bob_ws.receive_json() update = _recv(bob_ws)
assert update["type"] == "reaction_update" assert update["type"] == "reaction_update"
assert update["reactions"][0]["emoji"] == "🎉" assert update["reactions"][0]["emoji"] == "🎉"
+10
View File
@@ -41,6 +41,16 @@ export function removeAvatar(): Promise<User> {
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' }) return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
} }
// Deliberately its own call, same reasoning as updateTheme above -- a
// manual override of the presence indicator, global (every room, not
// per-room), independent of display_name/theme.
export function updateAppearOffline(appearOffline: boolean): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ appear_offline: appearOffline }),
})
}
export function changePassword(currentPassword: string, newPassword: string): Promise<void> { export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return apiFetch<void>('/api/auth/password', { return apiFetch<void>('/api/auth/password', {
method: 'PATCH', method: 'PATCH',
+8
View File
@@ -8,3 +8,11 @@ export function getUserAvatarUrl(userId: string, avatarFilename?: string | null)
export function listUserDirectory(): Promise<UserDirectoryEntry[]> { export function listUserDirectory(): Promise<UserDirectoryEntry[]> {
return apiFetch<UserDirectoryEntry[]>('/api/users') return apiFetch<UserDirectoryEntry[]>('/api/users')
} }
// A snapshot, not a live feed -- see backend/app/routers/users.py. Good
// enough for surfaces that only need to be accurate as of page load (the
// admin user list, the room-invite user search); chat surfaces get live
// presence for free via the room member list instead.
export function listOnlineUserIds(): Promise<string[]> {
return apiFetch<string[]>('/api/users/online')
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping' import { avatarUrlFor, displayNameFor, senderColorIndex, statusFor } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types' import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker' import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal' import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
@@ -122,6 +122,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
username={msg.username} username={msg.username}
colorIndex={senderColorIndex(msg.username, members)} colorIndex={senderColorIndex(msg.username, members)}
avatarUrl={avatarUrlFor(msg.username, members)} avatarUrl={avatarUrlFor(msg.username, members)}
status={statusFor(msg.username, members)}
/> />
)} )}
</div> </div>
+1
View File
@@ -132,6 +132,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
colorIndex={hashIndex(user.username)} colorIndex={hashIndex(user.username)}
size={64} size={64}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
status={user.appear_offline ? 'offline' : 'online'}
/> />
<div className="profile-modal-avatar-actions"> <div className="profile-modal-avatar-actions">
<input <input
@@ -263,6 +263,7 @@ export function RoomInfoPanel({
colorIndex={i} colorIndex={i}
size={24} size={24}
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null} avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
status={m.status}
/> />
<span className="room-info-member-name">{m.display_name || m.username}</span> <span className="room-info-member-name">{m.display_name || m.username}</span>
{actions.length > 0 ? ( {actions.length > 0 ? (
+29 -1
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import logo from '../assets/logo.png' import logo from '../assets/logo.png'
import { updateAppearOffline } from '../api/auth'
import { ApiError } from '../api/client'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
@@ -10,13 +12,15 @@ import { UserAvatar } from './UserAvatar'
import './TopBar.css' import './TopBar.css'
export function TopBar() { export function TopBar() {
const { user, logout } = useAuth() const { user, updateUser, logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false) const [profileModalOpen, setProfileModalOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false) const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false) const [pushBusy, setPushBusy] = useState(false)
const [pushError, setPushError] = useState<string | null>(null) const [pushError, setPushError] = useState<string | null>(null)
const [presenceBusy, setPresenceBusy] = useState(false)
const [presenceError, setPresenceError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
getPushSubscriptionStatus().then(setPushSubscribed) getPushSubscriptionStatus().then(setPushSubscribed)
@@ -40,6 +44,20 @@ export function TopBar() {
} }
} }
async function handleTogglePresence() {
if (!user) return
setPresenceBusy(true)
setPresenceError(null)
try {
const updated = await updateAppearOffline(!user.appear_offline)
updateUser(updated)
} catch (err) {
setPresenceError(err instanceof ApiError ? err.message : String(err))
} finally {
setPresenceBusy(false)
}
}
if (!user) return null if (!user) return null
return ( return (
@@ -62,6 +80,7 @@ export function TopBar() {
colorIndex={hashIndex(user.username)} colorIndex={hashIndex(user.username)}
size={30} size={30}
avatarUrl={user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null} avatarUrl={user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null}
status={user.appear_offline ? 'offline' : 'online'}
/> />
</button> </button>
{menuOpen && ( {menuOpen && (
@@ -79,6 +98,15 @@ export function TopBar() {
> >
Profile settings Profile settings
</button> </button>
<button
type="button"
role="menuitem"
onClick={handleTogglePresence}
disabled={presenceBusy}
>
{user.appear_offline ? 'Show as online' : 'Appear offline'}
</button>
{presenceError && <div className="top-bar-menu-error">{presenceError}</div>}
{user.is_site_admin && ( {user.is_site_admin && (
<button <button
type="button" type="button"
+22
View File
@@ -1,3 +1,9 @@
.user-avatar-wrap {
position: relative;
display: inline-flex;
flex: none;
}
.user-avatar { .user-avatar {
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
flex: none; flex: none;
@@ -12,3 +18,19 @@
.user-avatar-img { .user-avatar-img {
object-fit: cover; object-fit: cover;
} }
.user-avatar-status-dot {
position: absolute;
right: -1px;
bottom: -1px;
border-radius: var(--radius-pill);
border: 2px solid var(--ds-surface);
}
.user-avatar-status-dot-online {
background: var(--ds-online);
}
.user-avatar-status-dot-offline {
background: var(--ds-danger);
}
+20 -1
View File
@@ -6,26 +6,45 @@ interface UserAvatarProps {
colorIndex: number colorIndex: number
size?: number size?: number
avatarUrl?: string | null avatarUrl?: string | null
// undefined -- no presence data for this context (e.g. a bot), don't
// render a dot at all, rather than guessing.
status?: 'online' | 'offline'
} }
export function UserAvatar({ username, colorIndex, size = 28, avatarUrl }: UserAvatarProps) { export function UserAvatar({ username, colorIndex, size = 28, avatarUrl, status }: UserAvatarProps) {
const dotSize = Math.max(8, Math.round(size * 0.32))
const dot = status && (
<span
className={`user-avatar-status-dot user-avatar-status-dot-${status}`}
style={{ width: dotSize, height: dotSize }}
aria-label={status === 'online' ? 'Online' : 'Offline'}
title={status === 'online' ? 'Online' : 'Offline'}
/>
)
if (avatarUrl) { if (avatarUrl) {
return ( return (
<span className="user-avatar-wrap" style={{ width: size, height: size }}>
<img <img
src={avatarUrl} src={avatarUrl}
alt="" alt=""
className="user-avatar user-avatar-img" className="user-avatar user-avatar-img"
style={{ width: size, height: size }} style={{ width: size, height: size }}
/> />
{dot}
</span>
) )
} }
return ( return (
<span className="user-avatar-wrap" style={{ width: size, height: size }}>
<div <div
className="user-avatar" className="user-avatar"
style={{ width: size, height: size, background: accentForIndex(colorIndex) }} style={{ width: size, height: size, background: accentForIndex(colorIndex) }}
> >
{initials(username)} {initials(username)}
</div> </div>
{dot}
</span>
) )
} }
+13 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl, listOnlineUserIds } from '../api/users'
import type { UserDirectoryEntry } from '../types' import type { UserDirectoryEntry } from '../types'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './UserPicker.css' import './UserPicker.css'
@@ -16,6 +16,17 @@ export function UserPicker({ users, excludeUserIds, placeholder = 'Search users
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0) const [highlighted, setHighlighted] = useState(0)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
// A snapshot fetched once, not live -- see listOnlineUserIds's own
// comment. Fine for a search dropdown that's only open briefly.
const [onlineIds, setOnlineIds] = useState<Set<string>>(new Set())
useEffect(() => {
listOnlineUserIds()
.then((ids) => setOnlineIds(new Set(ids)))
.catch(() => {
// Non-critical -- the picker still works, just without dots.
})
}, [])
const excluded = new Set(excludeUserIds ?? []) const excluded = new Set(excludeUserIds ?? [])
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
@@ -89,6 +100,7 @@ export function UserPicker({ users, excludeUserIds, placeholder = 'Search users
colorIndex={i} colorIndex={i}
size={22} size={22}
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null} avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
status={onlineIds.has(u.id) ? 'online' : 'offline'}
/> />
<span className="user-picker-row-name">{u.display_name || u.username}</span> <span className="user-picker-row-name">{u.display_name || u.username}</span>
{u.display_name && <span className="user-picker-row-username">@{u.username}</span>} {u.display_name && <span className="user-picker-row-username">@{u.username}</span>}
+4
View File
@@ -20,3 +20,7 @@ export function displayNameFor(username: string, members: RoomMember[]): string
const member = members.find((m) => m.username === username) const member = members.find((m) => m.username === username)
return member?.display_name || username return member?.display_name || username
} }
export function statusFor(username: string, members: RoomMember[]): 'online' | 'offline' | undefined {
return members.find((m) => m.username === username)?.status
}
+10 -1
View File
@@ -25,7 +25,7 @@ import {
} from '../api/admin' } from '../api/admin'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots' import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl, listOnlineUserIds } from '../api/users'
import { UserPicker } from '../components/UserPicker' import { UserPicker } from '../components/UserPicker'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
@@ -55,6 +55,9 @@ export function AdminPage() {
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const [tab, setTab] = useState<Tab>('users') const [tab, setTab] = useState<Tab>('users')
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
// A snapshot, not live -- see listOnlineUserIds's own comment. Reloaded
// whenever the Users tab is opened, same cadence as the user list itself.
const [onlineIds, setOnlineIds] = useState<Set<string>>(new Set())
const [rooms, setRooms] = useState<AdminRoom[]>([]) const [rooms, setRooms] = useState<AdminRoom[]>([])
const [transferringRoomId, setTransferringRoomId] = useState<string | null>(null) const [transferringRoomId, setTransferringRoomId] = useState<string | null>(null)
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([]) const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
@@ -98,6 +101,11 @@ export function AdminPage() {
function loadUsers() { function loadUsers() {
listAdminUsers().then(setUsers).catch(reportError) listAdminUsers().then(setUsers).catch(reportError)
listOnlineUserIds()
.then((ids) => setOnlineIds(new Set(ids)))
.catch(() => {
// Non-critical -- the table still works, just without dots.
})
} }
function loadRooms() { function loadRooms() {
@@ -449,6 +457,7 @@ export function AdminPage() {
colorIndex={hashIndex(u.username)} colorIndex={hashIndex(u.username)}
size={28} size={28}
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null} avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
status={onlineIds.has(u.id) ? 'online' : 'offline'}
/> />
</td> </td>
<td>{u.display_name || u.username}</td> <td>{u.display_name || u.username}</td>
+6
View File
@@ -21,6 +21,12 @@
in the same neon-on-black family since the guide has no semantic red. */ in the same neon-on-black family since the guide has no semantic red. */
--ds-danger: #fc6060; --ds-danger: #fc6060;
/* Presence dot -- offline reuses --ds-danger (already themed per-palette
below) rather than a second red token; online has no existing
equivalent so it gets one. Deliberately not overridden per-theme: a
universally recognizable green/red pair, not a brand color. */
--ds-online: #22c55e;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; --mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+2
View File
@@ -9,6 +9,7 @@ export interface User {
display_name: string | null display_name: string | null
theme: ThemeName | null theme: ThemeName | null
avatar_filename: string | null avatar_filename: string | null
appear_offline: boolean
created_at: string created_at: string
} }
@@ -45,6 +46,7 @@ export interface RoomMember {
avatar_filename: string | null avatar_filename: string | null
role: RoomRole role: RoomRole
joined_at: string joined_at: string
status: 'online' | 'offline'
} }
export type InviteStatus = 'pending' | 'accepted' | 'revoked' export type InviteStatus = 'pending' | 'accepted' | 'revoked'