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