Private
Public Access
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:
@@ -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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
@@ -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"] == "🎉"
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@ export function removeAvatar(): Promise<User> {
|
||||
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> {
|
||||
return apiFetch<void>('/api/auth/password', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -8,3 +8,11 @@ export function getUserAvatarUrl(userId: string, avatarFilename?: string | null)
|
||||
export function listUserDirectory(): Promise<UserDirectoryEntry[]> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||
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 { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
|
||||
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
|
||||
@@ -122,6 +122,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
|
||||
username={msg.username}
|
||||
colorIndex={senderColorIndex(msg.username, members)}
|
||||
avatarUrl={avatarUrlFor(msg.username, members)}
|
||||
status={statusFor(msg.username, members)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,6 +132,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||
colorIndex={hashIndex(user.username)}
|
||||
size={64}
|
||||
avatarUrl={avatarUrl}
|
||||
status={user.appear_offline ? 'offline' : 'online'}
|
||||
/>
|
||||
<div className="profile-modal-avatar-actions">
|
||||
<input
|
||||
|
||||
@@ -263,6 +263,7 @@ export function RoomInfoPanel({
|
||||
colorIndex={i}
|
||||
size={24}
|
||||
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>
|
||||
{actions.length > 0 ? (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import logo from '../assets/logo.png'
|
||||
import { updateAppearOffline } from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
@@ -10,13 +12,15 @@ import { UserAvatar } from './UserAvatar'
|
||||
import './TopBar.css'
|
||||
|
||||
export function TopBar() {
|
||||
const { user, logout } = useAuth()
|
||||
const { user, updateUser, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false)
|
||||
const [pushSubscribed, setPushSubscribed] = useState(false)
|
||||
const [pushBusy, setPushBusy] = useState(false)
|
||||
const [pushError, setPushError] = useState<string | null>(null)
|
||||
const [presenceBusy, setPresenceBusy] = useState(false)
|
||||
const [presenceError, setPresenceError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -62,6 +80,7 @@ export function TopBar() {
|
||||
colorIndex={hashIndex(user.username)}
|
||||
size={30}
|
||||
avatarUrl={user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null}
|
||||
status={user.appear_offline ? 'offline' : 'online'}
|
||||
/>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
@@ -79,6 +98,15 @@ export function TopBar() {
|
||||
>
|
||||
Profile settings
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.user-avatar-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border-radius: var(--radius-pill);
|
||||
flex: none;
|
||||
@@ -12,3 +18,19 @@
|
||||
.user-avatar-img {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -6,26 +6,45 @@ interface UserAvatarProps {
|
||||
colorIndex: number
|
||||
size?: number
|
||||
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) {
|
||||
return (
|
||||
<span className="user-avatar-wrap" style={{ width: size, height: size }}>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="user-avatar user-avatar-img"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
{dot}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="user-avatar-wrap" style={{ width: size, height: size }}>
|
||||
<div
|
||||
className="user-avatar"
|
||||
style={{ width: size, height: size, background: accentForIndex(colorIndex) }}
|
||||
>
|
||||
{initials(username)}
|
||||
</div>
|
||||
{dot}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { getUserAvatarUrl, listOnlineUserIds } from '../api/users'
|
||||
import type { UserDirectoryEntry } from '../types'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import './UserPicker.css'
|
||||
@@ -16,6 +16,17 @@ export function UserPicker({ users, excludeUserIds, placeholder = 'Search users
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(0)
|
||||
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 q = query.trim().toLowerCase()
|
||||
@@ -89,6 +100,7 @@ export function UserPicker({ users, excludeUserIds, placeholder = 'Search users
|
||||
colorIndex={i}
|
||||
size={22}
|
||||
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>
|
||||
{u.display_name && <span className="user-picker-row-username">@{u.username}</span>}
|
||||
|
||||
@@ -20,3 +20,7 @@ export function displayNameFor(username: string, members: RoomMember[]): string
|
||||
const member = members.find((m) => m.username === username)
|
||||
return member?.display_name || username
|
||||
}
|
||||
|
||||
export function statusFor(username: string, members: RoomMember[]): 'online' | 'offline' | undefined {
|
||||
return members.find((m) => m.username === username)?.status
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../api/admin'
|
||||
import { ApiError } from '../api/client'
|
||||
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 { useAuth } from '../context/AuthContext'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
@@ -55,6 +55,9 @@ export function AdminPage() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [tab, setTab] = useState<Tab>('users')
|
||||
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 [transferringRoomId, setTransferringRoomId] = useState<string | null>(null)
|
||||
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
|
||||
@@ -98,6 +101,11 @@ export function AdminPage() {
|
||||
|
||||
function loadUsers() {
|
||||
listAdminUsers().then(setUsers).catch(reportError)
|
||||
listOnlineUserIds()
|
||||
.then((ids) => setOnlineIds(new Set(ids)))
|
||||
.catch(() => {
|
||||
// Non-critical -- the table still works, just without dots.
|
||||
})
|
||||
}
|
||||
|
||||
function loadRooms() {
|
||||
@@ -449,6 +457,7 @@ export function AdminPage() {
|
||||
colorIndex={hashIndex(u.username)}
|
||||
size={28}
|
||||
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
|
||||
status={onlineIds.has(u.id) ? 'online' : 'offline'}
|
||||
/>
|
||||
</td>
|
||||
<td>{u.display_name || u.username}</td>
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
in the same neon-on-black family since the guide has no semantic red. */
|
||||
--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;
|
||||
--mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface User {
|
||||
display_name: string | null
|
||||
theme: ThemeName | null
|
||||
avatar_filename: string | null
|
||||
appear_offline: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export interface RoomMember {
|
||||
avatar_filename: string | null
|
||||
role: RoomRole
|
||||
joined_at: string
|
||||
status: 'online' | 'offline'
|
||||
}
|
||||
|
||||
export type InviteStatus = 'pending' | 'accepted' | 'revoked'
|
||||
|
||||
Reference in New Issue
Block a user