diff --git a/backend/alembic/versions/f0f6e494454a_user_appear_offline_presence_override.py b/backend/alembic/versions/f0f6e494454a_user_appear_offline_presence_override.py new file mode 100644 index 0000000..2bfe200 --- /dev/null +++ b/backend/alembic/versions/f0f6e494454a_user_appear_offline_presence_override.py @@ -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 ### diff --git a/backend/app/main.py b/backend/app/main.py index a578bff..53f2850 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6754509..0ac10a0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 68f4fda..9d3f470 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index bedcd91..a318f34 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -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), ) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 22e0b29..b23a46f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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, diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py index 166efb6..98a26d5 100644 --- a/backend/app/schemas/room.py +++ b/backend/app/schemas/room.py @@ -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): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index cd71bcf..27a4501 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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): diff --git a/backend/app/ws/chat.py b/backend/app/ws/chat.py index bef28e0..ea1d8b8 100644 --- a/backend/app/ws/chat.py +++ b/backend/app/ws/chat.py @@ -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) diff --git a/backend/app/ws/global_presence.py b/backend/app/ws/global_presence.py new file mode 100644 index 0000000..e27ee76 --- /dev/null +++ b/backend/app/ws/global_presence.py @@ -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} diff --git a/backend/tests/test_broadcast.py b/backend/tests/test_broadcast.py index d55455c..cb9a3f6 100644 --- a/backend/tests/test_broadcast.py +++ b/backend/tests/test_broadcast.py @@ -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 diff --git a/backend/tests/test_message_edit.py b/backend/tests/test_message_edit.py index 49f06eb..92307aa 100644 --- a/backend/tests/test_message_edit.py +++ b/backend/tests/test_message_edit.py @@ -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" diff --git a/backend/tests/test_presence.py b/backend/tests/test_presence.py new file mode 100644 index 0000000..4c62acf --- /dev/null +++ b/backend/tests/test_presence.py @@ -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 diff --git a/backend/tests/test_push.py b/backend/tests/test_push.py index 1c48ec7..246ca43 100644 --- a/backend/tests/test_push.py +++ b/backend/tests/test_push.py @@ -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 == [] diff --git a/backend/tests/test_reactions.py b/backend/tests/test_reactions.py index 13241de..b53679e 100644 --- a/backend/tests/test_reactions.py +++ b/backend/tests/test_reactions.py @@ -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"] == "🎉" diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 16544fc..3b5f523 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -41,6 +41,16 @@ export function removeAvatar(): Promise { return apiFetch('/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 { + return apiFetch('/api/auth/me', { + method: 'PATCH', + body: JSON.stringify({ appear_offline: appearOffline }), + }) +} + export function changePassword(currentPassword: string, newPassword: string): Promise { return apiFetch('/api/auth/password', { method: 'PATCH', diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 440c4a7..609a8dc 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -8,3 +8,11 @@ export function getUserAvatarUrl(userId: string, avatarFilename?: string | null) export function listUserDirectory(): Promise { return apiFetch('/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 { + return apiFetch('/api/users/online') +} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 871b8f6..050b09a 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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)} /> )} diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index 9c3491f..1b639c0 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -132,6 +132,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) { colorIndex={hashIndex(user.username)} size={64} avatarUrl={avatarUrl} + status={user.appear_offline ? 'offline' : 'online'} />
{m.display_name || m.username} {actions.length > 0 ? ( diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index f88650d..0195083 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -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(null) + const [presenceBusy, setPresenceBusy] = useState(false) + const [presenceError, setPresenceError] = useState(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'} /> {menuOpen && ( @@ -79,6 +98,15 @@ export function TopBar() { > Profile settings + + {presenceError &&
{presenceError}
} {user.is_site_admin && (