Add a People list showing who's online (#25)

A "People" button next to "Browse rooms" opens a modal listing every
site user with an online/offline status dot, online users sorted
first. No backend changes needed -- GET /api/users (the user
directory) and GET /api/users/online (a snapshot of who's connected
anywhere in the app, backing every avatar's status dot already) both
already existed from other features, just never had a UI surface of
their own for regular members.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 18:07:37 -06:00
co-authored by Claude Sonnet 5
parent 77809758a2
commit c094ce0975
3 changed files with 108 additions and 1 deletions
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from 'react'
import { ApiError } from '../api/client'
import { getUserAvatarUrl, listOnlineUserIds, listUserDirectory } from '../api/users'
import { hashIndex } from '../lib/avatar'
import type { UserDirectoryEntry } from '../types'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
interface PeopleModalProps {
onClose: () => void
}
// #25: a snapshot on open, not a live feed -- matches listOnlineUserIds'
// own documented contract (also used as-is by the admin user list and the
// room-invite search), rather than inventing a new live-updating design
// for this first pass.
export function PeopleModal({ onClose }: PeopleModalProps) {
const [users, setUsers] = useState<UserDirectoryEntry[]>([])
const [onlineIds, setOnlineIds] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([listUserDirectory(), listOnlineUserIds()])
.then(([directory, online]) => {
setUsers(directory)
setOnlineIds(new Set(online))
})
.catch((err) => setError(err instanceof ApiError ? err.message : String(err)))
.finally(() => setLoading(false))
}, [])
// Online first (each group alphabetical, matching listUserDirectory's own
// username ordering) -- who's actually around right now is the more
// useful thing to see first in a list that can otherwise run to the
// entire site's user base.
const sorted = useMemo(
() =>
[...users].sort((a, b) => {
const aOnline = onlineIds.has(a.id)
const bOnline = onlineIds.has(b.id)
if (aOnline !== bOnline) return aOnline ? -1 : 1
return a.username.localeCompare(b.username)
}),
[users, onlineIds],
)
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>People</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
{error && <p className="modal-error">{error}</p>}
{loading ? (
<p className="modal-empty">Loading...</p>
) : sorted.length === 0 ? (
<p className="modal-empty">No users found.</p>
) : (
sorted.map((u) => {
const online = onlineIds.has(u.id)
return (
<div key={u.id} className="modal-list-row">
<UserAvatar
username={u.username}
colorIndex={hashIndex(u.username)}
size={30}
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
status={online ? 'online' : 'offline'}
/>
<div className="modal-list-row-body">
<div className="modal-list-row-title">{u.display_name || u.username}</div>
<div className="modal-list-row-sub">{online ? 'Online' : 'Offline'}</div>
</div>
</div>
)
})
)}
<div className="modal-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
)
}
+11
View File
@@ -10,6 +10,7 @@ interface SidebarProps {
onSearchChange: (value: string) => void onSearchChange: (value: string) => void
onOpenNewRoom: () => void onOpenNewRoom: () => void
onOpenBrowse: () => void onOpenBrowse: () => void
onOpenPeople: () => void
unavailableOffline?: boolean unavailableOffline?: boolean
} }
@@ -20,6 +21,7 @@ export function Sidebar({
onSearchChange, onSearchChange,
onOpenNewRoom, onOpenNewRoom,
onOpenBrowse, onOpenBrowse,
onOpenPeople,
unavailableOffline, unavailableOffline,
}: SidebarProps) { }: SidebarProps) {
const query = searchQuery.trim().toLowerCase() const query = searchQuery.trim().toLowerCase()
@@ -65,6 +67,15 @@ export function Sidebar({
</svg> </svg>
Browse rooms Browse rooms
</button> </button>
<button type="button" className="sidebar-entry" onClick={onOpenPeople}>
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="7" cy="6.5" r="3" />
<path d="M2 17c0-3 2.5-5 5-5s5 2 5 5" strokeLinecap="round" />
<circle cx="14.5" cy="7.5" r="2.3" />
<path d="M12.7 12.3c2-.3 4 1.2 4.8 3.7" strokeLinecap="round" />
</svg>
People
</button>
{unavailableOffline ? ( {unavailableOffline ? (
<p className="sidebar-offline-note"> <p className="sidebar-offline-note">
+4 -1
View File
@@ -6,6 +6,7 @@ import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
import { ChatPane } from '../components/ChatPane' import { ChatPane } from '../components/ChatPane'
import { NewRoomModal } from '../components/NewRoomModal' import { NewRoomModal } from '../components/NewRoomModal'
import { OfflineBanner } from '../components/OfflineBanner' import { OfflineBanner } from '../components/OfflineBanner'
import { PeopleModal } from '../components/PeopleModal'
import { RoomInfoPanel } from '../components/RoomInfoPanel' import { RoomInfoPanel } from '../components/RoomInfoPanel'
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
@@ -15,7 +16,7 @@ import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import './ChatShellPage.css' import './ChatShellPage.css'
type ModalKind = 'new' | 'browse' | null type ModalKind = 'new' | 'browse' | 'people' | null
export function ChatShellPage() { export function ChatShellPage() {
const { roomId } = useParams<{ roomId?: string }>() const { roomId } = useParams<{ roomId?: string }>()
@@ -108,6 +109,7 @@ export function ChatShellPage() {
onSearchChange={setSearch} onSearchChange={setSearch}
onOpenNewRoom={() => setModal('new')} onOpenNewRoom={() => setModal('new')}
onOpenBrowse={() => setModal('browse')} onOpenBrowse={() => setModal('browse')}
onOpenPeople={() => setModal('people')}
unavailableOffline={roomsUnavailableOffline && rooms.length === 0} unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
/> />
)} )}
@@ -173,6 +175,7 @@ export function ChatShellPage() {
}} }}
/> />
)} )}
{modal === 'people' && <PeopleModal onClose={() => setModal(null)} />}
</div> </div>
) )
} }