Private
Public Access
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:
@@ -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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface SidebarProps {
|
||||
onSearchChange: (value: string) => void
|
||||
onOpenNewRoom: () => void
|
||||
onOpenBrowse: () => void
|
||||
onOpenPeople: () => void
|
||||
unavailableOffline?: boolean
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export function Sidebar({
|
||||
onSearchChange,
|
||||
onOpenNewRoom,
|
||||
onOpenBrowse,
|
||||
onOpenPeople,
|
||||
unavailableOffline,
|
||||
}: SidebarProps) {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
@@ -65,6 +67,15 @@ export function Sidebar({
|
||||
</svg>
|
||||
Browse rooms
|
||||
</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 ? (
|
||||
<p className="sidebar-offline-note">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
|
||||
import { ChatPane } from '../components/ChatPane'
|
||||
import { NewRoomModal } from '../components/NewRoomModal'
|
||||
import { OfflineBanner } from '../components/OfflineBanner'
|
||||
import { PeopleModal } from '../components/PeopleModal'
|
||||
import { RoomInfoPanel } from '../components/RoomInfoPanel'
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import { TopBar } from '../components/TopBar'
|
||||
@@ -15,7 +16,7 @@ import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
||||
import type { MyRoomItem, RoomMember } from '../types'
|
||||
import './ChatShellPage.css'
|
||||
|
||||
type ModalKind = 'new' | 'browse' | null
|
||||
type ModalKind = 'new' | 'browse' | 'people' | null
|
||||
|
||||
export function ChatShellPage() {
|
||||
const { roomId } = useParams<{ roomId?: string }>()
|
||||
@@ -108,6 +109,7 @@ export function ChatShellPage() {
|
||||
onSearchChange={setSearch}
|
||||
onOpenNewRoom={() => setModal('new')}
|
||||
onOpenBrowse={() => setModal('browse')}
|
||||
onOpenPeople={() => setModal('people')}
|
||||
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
|
||||
/>
|
||||
)}
|
||||
@@ -173,6 +175,7 @@ export function ChatShellPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'people' && <PeopleModal onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user