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:
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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