import { useEffect, useState, type FormEvent } from 'react' import { ApiError } from '../api/client' import { getUserAvatarUrl, listUserDirectory } from '../api/users' import { addRoomMember, changeMemberRole, deleteRoom, getRoomFileUrl, getRoomImageUrl, hideDm, leaveRoom, listRoomAttachments, removeMember, transferOwnership, updateRoom, updateRoomNotifications, } from '../api/rooms' import { createEventSubscription, createIncomingWebhook, listEventSubscriptions, listIncomingWebhooks, revokeEventSubscription, revokeIncomingWebhook, } from '../api/webhooks' import { useAuth } from '../context/AuthContext' import { useResizableWidth } from '../hooks/useResizableWidth' import { hashIndex } from '../lib/avatar' import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth' import { formatFileSize } from '../lib/fileSize' import type { EventSubscription, EventType, MessageFileInfo, MyRoomItem, RoomAttachment, RoomMember, RoomRole, UserDirectoryEntry, WebhookIncoming, } from '../types' import { FilePreviewModal, getPreviewKind } from './FilePreviewModal' import { ImageLightbox } from './ImageLightbox' import { FileAttachmentIcon } from './MessageList' import { RoomAvatar } from './RoomAvatar' import { UserAvatar } from './UserAvatar' import { UserPicker } from './UserPicker' import './Modal.css' import './RoomInfoPanel.css' const EVENT_TYPES: EventType[] = ['message.created', 'message.updated'] // Disclosure chevron for the collapsible sections below (Files, // Integrations, Room settings) -- points right when collapsed, rotates 90° // clockwise (pointing down) when expanded, same convention as most // disclosure triangles rather than a +/- glyph. function DisclosureChevron({ open }: { open: boolean }) { return ( ) } interface RoomInfoPanelProps { room: MyRoomItem members: RoomMember[] onClose: () => void onMembersChanged: () => void onRoomUpdated: () => void onRoomDeleted: () => void onLeft: () => void } export function RoomInfoPanel({ room, members, onClose, onMembersChanged, onRoomUpdated, onRoomDeleted, onLeft, }: RoomInfoPanelProps) { const { user } = useAuth() const myRole = room.role const windowWidth = useWindowWidth() const isMobile = windowWidth < MOBILE_BREAKPOINT const { width, startResize } = useResizableWidth({ storageKey: 'room-info-panel-width', defaultWidth: 260, min: 260, max: 480, }) const [inviteError, setInviteError] = useState(null) const [directoryUsers, setDirectoryUsers] = useState([]) const [busyUserId, setBusyUserId] = useState(null) const [filesOpen, setFilesOpen] = useState(false) const [attachments, setAttachments] = useState([]) const [attachmentsError, setAttachmentsError] = useState(null) const [previewFile, setPreviewFile] = useState(null) const [lightboxSrc, setLightboxSrc] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [nameDraft, setNameDraft] = useState(room.name) const [descDraft, setDescDraft] = useState(room.description ?? '') const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private) const [roomError, setRoomError] = useState(null) const [emailNotifications, setEmailNotifications] = useState(room.email_notifications) const [notificationsError, setNotificationsError] = useState(null) const [integrationsOpen, setIntegrationsOpen] = useState(false) const [incomingWebhooks, setIncomingWebhooks] = useState([]) const [webhookDescription, setWebhookDescription] = useState('') const [eventSubscriptions, setEventSubscriptions] = useState([]) const [targetUrl, setTargetUrl] = useState('') const [selectedEventTypes, setSelectedEventTypes] = useState([]) const [newSigningSecret, setNewSigningSecret] = useState(null) const [integrationsError, setIntegrationsError] = useState(null) const canManage = myRole === 'admin' || myRole === 'owner' // #48: room owner, room admin, or site admin (regardless of their role in // *this* room) can edit room settings, including privacy -- matches the // backend PATCH /api/rooms/{id} gate exactly (see rooms.py). #52: never // for a DM regardless of role -- mirrors update_room's own // CannotModifyDmError guard, since a DM's `name` is an internal token, // not something editable. const canEditSettings = !room.is_dm && (canManage || !!user?.is_site_admin) useEffect(() => { setNameDraft(room.name) setDescDraft(room.description ?? '') setIsPrivateDraft(room.is_private) setEmailNotifications(room.email_notifications) if (canManage) { listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([])) listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([])) listUserDirectory().then(setDirectoryUsers).catch(() => setDirectoryUsers([])) } else { setIncomingWebhooks([]) setEventSubscriptions([]) setDirectoryUsers([]) } }, [room.id, room.name, room.description, room.is_private, room.email_notifications, canManage]) useEffect(() => { // Fetched lazily (only once expanded), not alongside the section above // -- unlike webhooks/directory this is visible to every member, not // just admins, so eagerly fetching on every room open would add a // request most opens never need. if (!filesOpen) return setAttachmentsError(null) listRoomAttachments(room.id) .then(setAttachments) .catch((err) => setAttachmentsError(err instanceof ApiError ? err.message : String(err))) }, [filesOpen, room.id]) async function handleToggleEmailNotifications(enabled: boolean) { const previous = emailNotifications setEmailNotifications(enabled) setNotificationsError(null) try { await updateRoomNotifications(room.id, enabled) onRoomUpdated() } catch (err) { setEmailNotifications(previous) setNotificationsError(err instanceof ApiError ? err.message : String(err)) } } async function handleAddMember(target: UserDirectoryEntry) { setInviteError(null) try { await addRoomMember(room.id, target.id) onMembersChanged() } catch (err) { setInviteError(err instanceof ApiError ? err.message : String(err)) } } async function handleCreateWebhook(e: FormEvent) { e.preventDefault() setIntegrationsError(null) try { await createIncomingWebhook(room.id, webhookDescription.trim()) setWebhookDescription('') setIncomingWebhooks(await listIncomingWebhooks(room.id)) } catch (err) { setIntegrationsError(err instanceof ApiError ? err.message : String(err)) } } async function handleRevokeWebhook(webhookId: string) { await revokeIncomingWebhook(room.id, webhookId) setIncomingWebhooks(await listIncomingWebhooks(room.id)) } function toggleEventType(eventType: EventType) { setSelectedEventTypes((prev) => prev.includes(eventType) ? prev.filter((t) => t !== eventType) : [...prev, eventType], ) } async function handleCreateSubscription(e: FormEvent) { e.preventDefault() setIntegrationsError(null) const url = targetUrl.trim() if (!url || selectedEventTypes.length === 0) return try { const created = await createEventSubscription(room.id, selectedEventTypes, url) setNewSigningSecret(created.signing_secret) setTargetUrl('') setSelectedEventTypes([]) setEventSubscriptions(await listEventSubscriptions(room.id)) } catch (err) { setIntegrationsError(err instanceof ApiError ? err.message : String(err)) } } async function handleRevokeSubscription(subscriptionId: string) { await revokeEventSubscription(room.id, subscriptionId) setEventSubscriptions(await listEventSubscriptions(room.id)) } async function handleRemove(userId: string) { setBusyUserId(userId) try { await removeMember(room.id, userId) onMembersChanged() } finally { setBusyUserId(null) } } async function handleRoleChange(userId: string, role: RoomRole) { setBusyUserId(userId) try { await changeMemberRole(room.id, userId, role) onMembersChanged() } finally { setBusyUserId(null) } } async function handleTransfer(userId: string) { if (!confirm('Transfer ownership to this member? You will become an admin.')) return setBusyUserId(userId) try { await transferOwnership(room.id, userId) onMembersChanged() onRoomUpdated() } finally { setBusyUserId(null) } } 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 await leaveRoom(room.id) onLeft() } async function handleHideDm() { const name = room.dm_partner?.display_name || room.dm_partner?.username || 'this conversation' if (!confirm(`Hide your conversation with ${name}? It'll come back if either of you sends a new message.`)) { return } await hideDm(room.id) onLeft() } async function handleSaveSettings(e: FormEvent) { e.preventDefault() setRoomError(null) try { await updateRoom(room.id, { name: nameDraft.trim(), description: descDraft.trim(), is_private: isPrivateDraft, }) onRoomUpdated() } catch (err) { setRoomError(err instanceof ApiError ? err.message : String(err)) } } async function handleDelete() { if (!confirm(`Delete #${room.name}? This removes all messages and can't be undone.`)) return await deleteRoom(room.id) onRoomDeleted() } return ( ) }