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
+2 -1
View File
@@ -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>
+1
View File
@@ -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 ? (
+29 -1
View File
@@ -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"
+22
View File
@@ -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);
}
+32 -13
View File
@@ -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 (
<img
src={avatarUrl}
alt=""
className="user-avatar user-avatar-img"
style={{ width: size, height: size }}
/>
<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 (
<div
className="user-avatar"
style={{ width: size, height: size, background: accentForIndex(colorIndex) }}
>
{initials(username)}
</div>
<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>
)
}
+13 -1
View File
@@ -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>}