diff --git a/frontend/src/components/PeopleModal.tsx b/frontend/src/components/PeopleModal.tsx new file mode 100644 index 0000000..1cb1207 --- /dev/null +++ b/frontend/src/components/PeopleModal.tsx @@ -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([]) + const [onlineIds, setOnlineIds] = useState>(new Set()) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
e.stopPropagation()}> +
+

People

+ +
+ + {error &&

{error}

} + + {loading ? ( +

Loading...

+ ) : sorted.length === 0 ? ( +

No users found.

+ ) : ( + sorted.map((u) => { + const online = onlineIds.has(u.id) + return ( +
+ +
+
{u.display_name || u.username}
+
{online ? 'Online' : 'Offline'}
+
+
+ ) + }) + )} + +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index bf9b219..82bad81 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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({ Browse rooms + {unavailableOffline ? (

diff --git a/frontend/src/pages/ChatShellPage.tsx b/frontend/src/pages/ChatShellPage.tsx index 6717719..464257a 100644 --- a/frontend/src/pages/ChatShellPage.tsx +++ b/frontend/src/pages/ChatShellPage.tsx @@ -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' && setModal(null)} />} ) }