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