Add resizable room panel, searchable user picker, and direct room membership

Room info panel is now user-resizable (fixing a layout clip at narrow
widths), and every user-selection spot (room membership, admin ownership
transfer) uses a new searchable UserPicker instead of raw text input or
prompt(). Member rows fold role + actions into a single inline dropdown
instead of a row of buttons, so the member list stays usable as rooms grow.

Room invites (the accept/decline flow) are replaced by adding a user to a
room directly -- an admin/owner picks someone and they're a member
immediately, with a "you've been added" notification email instead of an
invite email. Drops the now-unused room_invites table.
This commit is contained in:
2026-08-14 20:40:37 -06:00
parent b724f8a33b
commit 91589ee647
32 changed files with 735 additions and 1052 deletions
+41 -27
View File
@@ -24,6 +24,7 @@ import {
import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
import { getUserAvatarUrl } from '../api/users'
import { UserPicker } from '../components/UserPicker'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import type {
@@ -52,6 +53,7 @@ export function AdminPage() {
const [tab, setTab] = useState<Tab>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [rooms, setRooms] = useState<AdminRoom[]>([])
const [transferringRoomId, setTransferringRoomId] = useState<string | null>(null)
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
const [auditHasMore, setAuditHasMore] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
@@ -191,18 +193,16 @@ export function AdminPage() {
})
}
async function handleTransferOwnership(r: AdminRoom) {
const username = prompt(`Transfer #${r.name} to which username? (must already be a member)`)
if (!username) return
const target = users.find((u) => u.username === username.trim())
if (!target) {
setError(`No known user named "${username}"`)
return
}
function toggleTransfer(roomId: string) {
setTransferringRoomId((prev) => (prev === roomId ? null : roomId))
}
async function handleTransferOwnership(r: AdminRoom, targetId: string) {
await withBusy(r.id, async () => {
const updated = await transferOwnershipAdmin(r.id, target.id)
const updated = await transferOwnershipAdmin(r.id, targetId)
setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
})
setTransferringRoomId(null)
}
async function handleCreateBot() {
@@ -465,24 +465,38 @@ export function AdminPage() {
</thead>
<tbody>
{rooms.map((r) => (
<tr key={r.id}>
<td>#{r.name}</td>
<td>{r.is_private ? 'Private' : 'Open'}</td>
<td>
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
{r.is_archived ? 'Archived' : 'Active'}
</span>
</td>
<td>{r.member_count}</td>
<td className="admin-actions">
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
{r.is_archived ? 'Unarchive' : 'Archive'}
</button>
<button type="button" disabled={busyId === r.id} onClick={() => handleTransferOwnership(r)}>
Transfer ownership
</button>
</td>
</tr>
<Fragment key={r.id}>
<tr>
<td>#{r.name}</td>
<td>{r.is_private ? 'Private' : 'Open'}</td>
<td>
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
{r.is_archived ? 'Archived' : 'Active'}
</span>
</td>
<td>{r.member_count}</td>
<td className="admin-actions">
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
{r.is_archived ? 'Unarchive' : 'Archive'}
</button>
<button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
{transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
</button>
</td>
</tr>
{transferringRoomId === r.id && (
<tr>
<td colSpan={5} className="admin-bot-detail">
<UserPicker
users={users}
excludeUserIds={[r.owner_id]}
placeholder="Search users to transfer ownership to…"
onSelect={(target) => handleTransferOwnership(r, target.id)}
/>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
+1 -22
View File
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { listMyInvites } from '../api/invites'
import { listMyRooms, listRoomMembers } from '../api/rooms'
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
import { ChatPane } from '../components/ChatPane'
import { InvitesModal } from '../components/InvitesModal'
import { NewRoomModal } from '../components/NewRoomModal'
import { OfflineBanner } from '../components/OfflineBanner'
import { RoomInfoPanel } from '../components/RoomInfoPanel'
@@ -16,7 +14,7 @@ import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import type { MyRoomItem, RoomMember } from '../types'
import './ChatShellPage.css'
type ModalKind = 'new' | 'browse' | 'invites' | null
type ModalKind = 'new' | 'browse' | null
export function ChatShellPage() {
const { roomId } = useParams<{ roomId?: string }>()
@@ -28,7 +26,6 @@ export function ChatShellPage() {
const [rooms, setRooms] = useState<MyRoomItem[]>([])
const [search, setSearch] = useState('')
const [members, setMembers] = useState<RoomMember[]>([])
const [inviteCount, setInviteCount] = useState(0)
const [infoOpen, setInfoOpen] = useState(false)
const [modal, setModal] = useState<ModalKind>(null)
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
@@ -59,12 +56,6 @@ export function ChatShellPage() {
refreshRooms().catch(() => {})
}, [refreshRooms])
useEffect(() => {
listMyInvites()
.then((list) => setInviteCount(list.length))
.catch(() => {})
}, [])
useEffect(() => {
refreshMembers()
// Also re-run when the logged-in user's own profile changes (display
@@ -92,8 +83,6 @@ export function ChatShellPage() {
onSearchChange={setSearch}
onOpenNewRoom={() => setModal('new')}
onOpenBrowse={() => setModal('browse')}
onOpenInvites={() => setModal('invites')}
inviteCount={inviteCount}
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
/>
)}
@@ -156,16 +145,6 @@ export function ChatShellPage() {
}}
/>
)}
{modal === 'invites' && (
<InvitesModal
onClose={() => setModal(null)}
onInvitesChanged={setInviteCount}
onAccepted={(id) => {
setModal(null)
refreshRooms().then(() => goToRoom(id))
}}
/>
)}
</div>
)
}