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.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence
# 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.redis = Redis.from_url(settings.redis_url, decode_responses=True)
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.include_router(health.router)
+4
View File
@@ -21,6 +21,10 @@ class User(Base):
theme: Mapped[str | None] = mapped_column(String(20))
avatar_filename: Mapped[str | None] = mapped_column(String(64))
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(
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
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
+16
View File
@@ -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),
)
+20 -1
View File
@@ -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,
+5
View File
@@ -1,5 +1,6 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -43,6 +44,10 @@ class RoomMemberRead(BaseModel):
avatar_filename: str | None
role: RoomRole
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):
+6 -3
View File
@@ -22,17 +22,20 @@ class UserRead(BaseModel):
display_name: str | None
theme: str | None
avatar_filename: str | None
appear_offline: bool
created_at: datetime
class ProfileUpdate(BaseModel):
# Both fields are independently optional-and-settable -- the router
# only applies keys actually present in the request body
# 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 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)
# 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):
+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.services.bot_service import resolve_token
from app.services.message_events import (
broadcast_member_updated,
broadcast_message_update,
broadcast_new_message,
broadcast_reaction_update,
@@ -76,9 +77,17 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await websocket.accept()
manager = websocket.app.state.connection_manager
presence = websocket.app.state.presence
global_presence = websocket.app.state.global_presence
broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set()
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:
while True:
@@ -229,3 +238,5 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
manager.unregister_user(user.id, websocket)
for room_id in joined_rooms:
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]}"
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):
calls = []
@@ -61,7 +71,7 @@ def test_message_fans_out_across_instances(ws_client_factory):
)
assert alice_ws.receive_json()["type"] == "message"
received = bob_ws.receive_json()
received = _recv(bob_ws)
assert received["type"] == "message"
assert received["content"] == "hi from instance 1"
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
# presence were still process-local (pre-phase-5 behavior) he'd
# 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
# 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]}"
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:
async def _seed():
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"
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
message = alice_ws.receive_json()
assert bob_ws.receive_json()["type"] == "message"
assert _recv(bob_ws)["type"] == "message"
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"
update = bob_ws.receive_json()
update = _recv(bob_ws)
assert update["type"] == "message_update"
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]}"
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]:
async def _query():
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"})
assert alice_ws.receive_json()["type"] == "message"
# 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 == []
+12 -2
View File
@@ -9,6 +9,16 @@ def _unique(prefix: str) -> str:
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:
async def _seed():
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(
{"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "🎉"}
)
assert alice_ws.receive_json()["type"] == "reaction_update"
update = bob_ws.receive_json()
assert _recv(alice_ws)["type"] == "reaction_update"
update = _recv(bob_ws)
assert update["type"] == "reaction_update"
assert update["reactions"][0]["emoji"] == "🎉"