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
-29
View File
@@ -1,29 +0,0 @@
import { apiFetch } from './client'
import type { Invite, MyInvite, RoomMember } from '../types'
export function createInvite(roomId: string, targetUsername: string): Promise<Invite> {
return apiFetch<Invite>(`/api/rooms/${roomId}/invites`, {
method: 'POST',
body: JSON.stringify({ target_username: targetUsername }),
})
}
export function listRoomInvites(roomId: string): Promise<Invite[]> {
return apiFetch<Invite[]>(`/api/rooms/${roomId}/invites`)
}
export function revokeInvite(roomId: string, inviteId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/invites/${inviteId}`, { method: 'DELETE' })
}
export function listMyInvites(): Promise<MyInvite[]> {
return apiFetch<MyInvite[]>('/api/invites/mine')
}
export function acceptInvite(inviteId: string): Promise<RoomMember> {
return apiFetch<RoomMember>(`/api/invites/${inviteId}/accept`, { method: 'POST' })
}
export function declineInvite(inviteId: string): Promise<Invite> {
return apiFetch<Invite>(`/api/invites/${inviteId}/decline`, { method: 'POST' })
}
+7
View File
@@ -46,6 +46,13 @@ export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
}
export function addRoomMember(roomId: string, userId: string): Promise<RoomMember> {
return apiFetch<RoomMember>(`/api/rooms/${roomId}/members`, {
method: 'POST',
body: JSON.stringify({ user_id: userId }),
})
}
export function removeMember(roomId: string, userId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' })
}
+7
View File
@@ -1,3 +1,10 @@
import { apiFetch } from './client'
import type { UserDirectoryEntry } from '../types'
export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string {
return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}`
}
export function listUserDirectory(): Promise<UserDirectoryEntry[]> {
return apiFetch<UserDirectoryEntry[]>('/api/users')
}
-112
View File
@@ -1,112 +0,0 @@
import { useEffect, useState } from 'react'
import { acceptInvite, declineInvite, listMyInvites } from '../api/invites'
import { ApiError } from '../api/client'
import type { MyInvite } from '../types'
import './Modal.css'
interface InvitesModalProps {
onClose: () => void
onAccepted: (roomId: string) => void
onInvitesChanged: (count: number) => void
}
export function InvitesModal({ onClose, onAccepted, onInvitesChanged }: InvitesModalProps) {
const [invites, setInvites] = useState<MyInvite[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function refresh() {
const list = await listMyInvites()
setInvites(list)
onInvitesChanged(list.length)
}
useEffect(() => {
refresh()
.catch((err) => setError(err instanceof ApiError ? err.message : String(err)))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleAccept(invite: MyInvite) {
setBusyId(invite.id)
setError(null)
try {
await acceptInvite(invite.id)
await refresh()
onAccepted(invite.room_id)
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setBusyId(null)
}
}
async function handleDecline(invite: MyInvite) {
setBusyId(invite.id)
setError(null)
try {
await declineInvite(invite.id)
await refresh()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setBusyId(null)
}
}
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Your invites</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>
) : invites.length === 0 ? (
<p className="modal-empty">No pending invites.</p>
) : (
invites.map((invite) => (
<div key={invite.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">{invite.room_name}</div>
<div className="modal-list-row-sub">Invited by {invite.invited_by_username}</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
className="btn-secondary"
disabled={busyId === invite.id}
onClick={() => handleDecline(invite)}
>
Decline
</button>
<button
type="button"
className="btn-primary"
disabled={busyId === invite.id}
onClick={() => handleAccept(invite)}
>
Accept
</button>
</div>
</div>
))
)}
<div className="modal-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
)
}
+39 -30
View File
@@ -1,14 +1,33 @@
.room-info-panel {
width: 260px;
position: relative;
flex: none;
min-width: 260px;
max-width: 480px;
border-left: 1px solid var(--ds-border);
background: var(--ds-void-2);
padding: var(--sp-4);
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.room-info-resize-handle {
position: absolute;
top: 0;
bottom: 0;
left: -3px;
width: 6px;
cursor: col-resize;
z-index: 5;
touch-action: none;
}
.room-info-resize-handle:hover,
.room-info-resize-handle:active {
background: color-mix(in srgb, var(--ds-accent) 40%, transparent);
}
.room-info-header {
display: flex;
justify-content: space-between;
@@ -81,26 +100,27 @@
white-space: nowrap;
}
.room-info-member-actions {
display: flex;
gap: 6px;
width: 100%;
padding-left: 32px;
}
.room-info-member-actions button {
background: transparent;
border: 1px solid var(--ds-border);
color: var(--ds-muted);
font-size: 0.7rem;
padding: 3px 7px;
border-radius: 6px;
.room-info-role-select {
appearance: none;
-webkit-appearance: none;
font-family: inherit;
cursor: pointer;
padding-right: 20px;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 11px) 55%, calc(100% - 6px) 55%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
opacity: 0.85;
}
.room-info-member-actions button:hover {
color: var(--ds-text);
border-color: var(--ds-accent);
.room-info-role-select:hover:not(:disabled) {
opacity: 1;
}
.room-info-role-select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.role-badge {
@@ -135,6 +155,7 @@
.room-info-invite-form {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.room-info-invite-form input {
@@ -147,18 +168,6 @@
font-size: 0.84rem;
}
.room-info-pending {
margin-top: var(--sp-2);
}
.room-info-pending-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.82rem;
color: var(--ds-text);
padding: 5px 0;
}
.room-info-error {
color: var(--ds-danger);
+82 -114
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, type FormEvent } from 'react'
import { ApiError } from '../api/client'
import { createInvite, listRoomInvites, revokeInvite } from '../api/invites'
import { getUserAvatarUrl } from '../api/users'
import { getUserAvatarUrl, listUserDirectory } from '../api/users'
import {
addRoomMember,
changeMemberRole,
deleteRoom,
leaveRoom,
@@ -19,17 +19,19 @@ import {
revokeIncomingWebhook,
} from '../api/webhooks'
import { useAuth } from '../context/AuthContext'
import { useResizableWidth } from '../hooks/useResizableWidth'
import type {
EventSubscription,
EventType,
Invite,
MyRoomItem,
RoomMember,
RoomRole,
UserDirectoryEntry,
WebhookIncoming,
} from '../types'
import { RoomAvatar } from './RoomAvatar'
import { UserAvatar } from './UserAvatar'
import { UserPicker } from './UserPicker'
import './RoomInfoPanel.css'
const EVENT_TYPES: EventType[] = ['message.created', 'message.updated']
@@ -55,10 +57,15 @@ export function RoomInfoPanel({
}: RoomInfoPanelProps) {
const { user } = useAuth()
const myRole = room.role
const { width, startResize } = useResizableWidth({
storageKey: 'room-info-panel-width',
defaultWidth: 260,
min: 260,
max: 480,
})
const [inviteUsername, setInviteUsername] = useState('')
const [inviteError, setInviteError] = useState<string | null>(null)
const [pendingInvites, setPendingInvites] = useState<Invite[]>([])
const [directoryUsers, setDirectoryUsers] = useState<UserDirectoryEntry[]>([])
const [busyUserId, setBusyUserId] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [nameDraft, setNameDraft] = useState(room.name)
@@ -80,35 +87,26 @@ export function RoomInfoPanel({
setNameDraft(room.name)
setDescDraft(room.description ?? '')
if (canManage) {
listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([]))
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
listUserDirectory().then(setDirectoryUsers).catch(() => setDirectoryUsers([]))
} else {
setPendingInvites([])
setIncomingWebhooks([])
setEventSubscriptions([])
setDirectoryUsers([])
}
}, [room.id, room.name, room.description, canManage])
async function handleInvite(e: FormEvent) {
e.preventDefault()
const username = inviteUsername.trim()
if (!username) return
async function handleAddMember(target: UserDirectoryEntry) {
setInviteError(null)
try {
await createInvite(room.id, username)
setInviteUsername('')
setPendingInvites(await listRoomInvites(room.id))
await addRoomMember(room.id, target.id)
onMembersChanged()
} catch (err) {
setInviteError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleRevoke(inviteId: string) {
await revokeInvite(room.id, inviteId)
setPendingInvites(await listRoomInvites(room.id))
}
async function handleCreateWebhook(e: FormEvent) {
e.preventDefault()
setIntegrationsError(null)
@@ -185,6 +183,29 @@ export function RoomInfoPanel({
}
}
function memberActions(m: RoomMember): { value: string; label: string }[] {
if (m.user_id === user?.id) return []
if (myRole === 'owner') {
const actions: { value: string; label: string }[] = []
if (m.role === 'member') actions.push({ value: 'promote', label: 'Promote to admin' })
if (m.role === 'admin') actions.push({ value: 'demote', label: 'Demote to member' })
actions.push({ value: 'transfer', label: 'Make owner' })
actions.push({ value: 'remove', label: 'Remove from room' })
return actions
}
if (myRole === 'admin' && m.role === 'member') {
return [{ value: 'remove', label: 'Remove from room' }]
}
return []
}
function handleMemberAction(userId: string, action: string) {
if (action === 'promote') handleRoleChange(userId, 'admin')
else if (action === 'demote') handleRoleChange(userId, 'member')
else if (action === 'transfer') handleTransfer(userId)
else if (action === 'remove') handleRemove(userId)
}
async function handleLeave() {
if (myRole === 'owner') return
if (!confirm(`Leave #${room.name}?`)) return
@@ -210,7 +231,8 @@ export function RoomInfoPanel({
}
return (
<aside className="room-info-panel">
<aside className="room-info-panel" style={{ width }}>
<div className="room-info-resize-handle" onPointerDown={startResize} />
<div className="room-info-header">
<span className="room-info-header-label">Details</span>
<button type="button" className="room-info-close" onClick={onClose} aria-label="Close">
@@ -229,107 +251,53 @@ export function RoomInfoPanel({
<div className="room-info-section">
<div className="room-info-label">Members</div>
{members.map((m, i) => (
<div key={m.user_id} className="room-info-member-row">
<UserAvatar
username={m.username}
colorIndex={i}
size={24}
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
/>
<span className="room-info-member-name">{m.display_name || m.username}</span>
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
{myRole === 'owner' && m.user_id !== user?.id && (
<div className="room-info-member-actions">
{m.role === 'member' && (
<button
type="button"
disabled={busyUserId === m.user_id}
onClick={() => handleRoleChange(m.user_id, 'admin')}
title="Promote to admin"
>
Promote
</button>
)}
{m.role === 'admin' && (
<button
type="button"
disabled={busyUserId === m.user_id}
onClick={() => handleRoleChange(m.user_id, 'member')}
title="Demote to member"
>
Demote
</button>
)}
<button
type="button"
{members.map((m, i) => {
const actions = memberActions(m)
return (
<div key={m.user_id} className="room-info-member-row">
<UserAvatar
username={m.username}
colorIndex={i}
size={24}
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
/>
<span className="room-info-member-name">{m.display_name || m.username}</span>
{actions.length > 0 ? (
<select
className={`role-badge role-badge-${m.role} room-info-role-select`}
value={m.role}
disabled={busyUserId === m.user_id}
onClick={() => handleTransfer(m.user_id)}
title="Transfer ownership"
onChange={(e) => {
const action = e.target.value
if (action && action !== m.role) handleMemberAction(m.user_id, action)
}}
aria-label={`Role and actions for ${m.display_name || m.username}`}
>
Make owner
</button>
<button
type="button"
className="room-info-danger-link"
disabled={busyUserId === m.user_id}
onClick={() => handleRemove(m.user_id)}
title="Remove from room"
>
Remove
</button>
</div>
)}
{myRole === 'admin' && m.role === 'member' && m.user_id !== user?.id && (
<div className="room-info-member-actions">
<button
type="button"
className="room-info-danger-link"
disabled={busyUserId === m.user_id}
onClick={() => handleRemove(m.user_id)}
title="Remove from room"
>
Remove
</button>
</div>
)}
</div>
))}
<option value={m.role}>{m.role[0].toUpperCase() + m.role.slice(1)}</option>
{actions.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
) : (
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
)}
</div>
)
})}
</div>
{canManage && (
<div className="room-info-section">
<div className="room-info-label">Invite someone</div>
<form className="room-info-invite-form" onSubmit={handleInvite}>
<input
type="text"
placeholder="Username"
value={inviteUsername}
onChange={(e) => setInviteUsername(e.target.value)}
/>
<button type="submit" className="btn-secondary">
Invite
</button>
</form>
<div className="room-info-label">Add someone</div>
<UserPicker
users={directoryUsers}
excludeUserIds={members.map((m) => m.user_id)}
placeholder="Search users to add…"
onSelect={handleAddMember}
/>
{inviteError && <p className="room-info-error">{inviteError}</p>}
{pendingInvites.length > 0 && (
<div className="room-info-pending">
{pendingInvites.map((inv) => (
<div key={inv.id} className="room-info-pending-row">
<span>{inv.target_username ?? 'Unknown user'}</span>
<button
type="button"
className="room-info-danger-link"
onClick={() => handleRevoke(inv.id)}
title="Revoke invite"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
)}
-15
View File
@@ -80,21 +80,6 @@
background: color-mix(in srgb, var(--ds-text) 5%, transparent);
}
.sidebar-badge {
margin-left: auto;
background: var(--ds-accent);
color: var(--ds-void);
font-size: 0.68rem;
font-weight: 800;
border-radius: var(--radius-pill);
min-width: 16px;
height: 16px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-section-label {
padding: 10px 16px 6px;
font-size: 0.72rem;
-13
View File
@@ -9,8 +9,6 @@ interface SidebarProps {
onSearchChange: (value: string) => void
onOpenNewRoom: () => void
onOpenBrowse: () => void
onOpenInvites: () => void
inviteCount: number
unavailableOffline?: boolean
}
@@ -21,8 +19,6 @@ export function Sidebar({
onSearchChange,
onOpenNewRoom,
onOpenBrowse,
onOpenInvites,
inviteCount,
unavailableOffline,
}: SidebarProps) {
const query = searchQuery.trim().toLowerCase()
@@ -52,15 +48,6 @@ export function Sidebar({
</div>
<div className="sidebar-scroll">
<button type="button" className="sidebar-entry" onClick={onOpenInvites}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M22 6 12 13 2 6" />
<rect x="2" y="4" width="20" height="16" rx="2" />
</svg>
Invites
{inviteCount > 0 && <span className="sidebar-badge">{inviteCount}</span>}
</button>
<button type="button" className="sidebar-entry" onClick={onOpenBrowse}>
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="9" cy="9" r="6" />
+72
View File
@@ -0,0 +1,72 @@
.user-picker {
position: relative;
flex: 1;
min-width: 0;
}
.user-picker input {
width: 100%;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 7px 10px;
color: var(--ds-text);
font-size: 0.84rem;
}
.user-picker-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 20;
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 4px;
}
.user-picker-row {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
text-align: left;
font-size: 0.82rem;
color: var(--ds-text);
width: 100%;
}
.user-picker-row-active,
.user-picker-row:hover {
background: color-mix(in srgb, var(--ds-accent) 16%, transparent);
}
.user-picker-row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker-row-username {
color: var(--ds-muted);
font-size: 0.74rem;
margin-left: auto;
padding-left: 6px;
flex: none;
}
.user-picker-empty {
padding: 8px 10px;
font-size: 0.8rem;
color: var(--ds-muted);
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import { getUserAvatarUrl } from '../api/users'
import type { UserDirectoryEntry } from '../types'
import { UserAvatar } from './UserAvatar'
import './UserPicker.css'
interface UserPickerProps {
users: UserDirectoryEntry[]
excludeUserIds?: string[]
placeholder?: string
onSelect: (user: UserDirectoryEntry) => void
}
export function UserPicker({ users, excludeUserIds, placeholder = 'Search users…', onSelect }: UserPickerProps) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const rootRef = useRef<HTMLDivElement>(null)
const excluded = new Set(excludeUserIds ?? [])
const q = query.trim().toLowerCase()
const matches = q
? users
.filter((u) => !excluded.has(u.id))
.filter((u) => u.username.toLowerCase().includes(q) || (u.display_name ?? '').toLowerCase().includes(q))
.slice(0, 8)
: []
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
function choose(u: UserDirectoryEntry) {
onSelect(u)
setQuery('')
setOpen(false)
setHighlighted(0)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!open || matches.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlighted((h) => Math.min(h + 1, matches.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlighted((h) => Math.max(h - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
choose(matches[highlighted])
} else if (e.key === 'Escape') {
setOpen(false)
}
}
return (
<div className="user-picker" ref={rootRef}>
<input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
setHighlighted(0)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
/>
{open && matches.length > 0 && (
<div className="user-picker-dropdown">
{matches.map((u, i) => (
<button
type="button"
key={u.id}
className={`user-picker-row${i === highlighted ? ' user-picker-row-active' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(u)}
onMouseEnter={() => setHighlighted(i)}
>
<UserAvatar
username={u.username}
colorIndex={i}
size={22}
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
/>
<span className="user-picker-row-name">{u.display_name || u.username}</span>
{u.display_name && <span className="user-picker-row-username">@{u.username}</span>}
</button>
))}
</div>
)}
{open && q && matches.length === 0 && <div className="user-picker-dropdown user-picker-empty">No matches</div>}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent } from 'react'
interface UseResizableWidthOptions {
storageKey: string
defaultWidth: number
min: number
max: number
}
// Right-anchored resizable panel: width is the distance from the cursor to
// the viewport's right edge, so a handle on the panel's left edge drags
// naturally. Persists to localStorage so it survives a reload.
export function useResizableWidth({ storageKey, defaultWidth, min, max }: UseResizableWidthOptions) {
const [width, setWidth] = useState(() => {
const stored = Number(localStorage.getItem(storageKey))
return stored >= min && stored <= max ? stored : defaultWidth
})
const widthRef = useRef(width)
widthRef.current = width
const draggingRef = useRef(false)
useEffect(() => {
function handleMove(e: PointerEvent<Window> | globalThis.PointerEvent) {
if (!draggingRef.current) return
const next = Math.min(max, Math.max(min, window.innerWidth - e.clientX))
setWidth(next)
}
function handleUp() {
if (!draggingRef.current) return
draggingRef.current = false
localStorage.setItem(storageKey, String(widthRef.current))
}
window.addEventListener('pointermove', handleMove as (e: globalThis.PointerEvent) => void)
window.addEventListener('pointerup', handleUp)
return () => {
window.removeEventListener('pointermove', handleMove as (e: globalThis.PointerEvent) => void)
window.removeEventListener('pointerup', handleUp)
}
}, [max, min, storageKey])
const startResize = useCallback((e: PointerEvent) => {
e.preventDefault()
draggingRef.current = true
}, [])
return { width, startResize }
}
+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>
)
}
+7 -16
View File
@@ -9,6 +9,13 @@ export interface User {
created_at: string
}
export interface UserDirectoryEntry {
id: string
username: string
display_name: string | null
avatar_filename: string | null
}
export type RoomRole = 'owner' | 'admin' | 'member'
export interface Room {
@@ -39,22 +46,6 @@ export interface RoomMember {
export type InviteStatus = 'pending' | 'accepted' | 'revoked'
export interface Invite {
id: string
room_id: string
invited_by: string
target_user_id: string | null
target_username: string | null
status: InviteStatus
expires_at: string
created_at: string
}
export interface MyInvite extends Invite {
room_name: string
invited_by_username: string
}
export interface ReactionSummary {
emoji: string
count: number