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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user