import React, { useState, useEffect, useCallback } from 'react'; import { Modal } from '../ui/Modal'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore'; import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions'; import { Toggle } from '../ui/Toggle'; import { PermissionsEditor } from '../ui/PermissionsEditor'; import type { PermissionDef } from '../ui/OverrideEntry'; // ─── Permission Definitions for Category Overrides ────────────────────────────── const CATEGORY_PERMISSIONS: PermissionDef[] = [ { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL }, { key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES }, { key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES }, { key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES }, { key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY }, { key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS }, { key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT }, { key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK }, { key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM }, { key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS }, { key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS }, { key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS }, { key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS }, ]; // ─── Overview Tab ─────────────────────────────────────────────────────────────── function OverviewTab({ categoryId, categoryName, isPrivate, isFetching, isLoading, error, canManageChannels, canManageRoles, onTogglePrivate, onDeleteCategory, onRename, }: { categoryId: string; categoryName: string; isPrivate: boolean; isFetching: boolean; isLoading: boolean; error: string; canManageChannels: boolean; canManageRoles: boolean; onTogglePrivate: () => void; onDeleteCategory: () => void; onRename: (name: string) => void; }) { const [editName, setEditName] = useState(categoryName); const [isSavingName, setIsSavingName] = useState(false); // Sync edit name when category name changes externally useEffect(() => { setEditName(categoryName); }, [categoryName]); const handleNameBlur = () => { const trimmed = editName.trim(); if (trimmed && trimmed !== categoryName) { setIsSavingName(true); onRename(trimmed); // The parent will update the store; we just reset the saving state after a short delay setTimeout(() => setIsSavingName(false), 500); } else { setEditName(categoryName); } }; const handleNameKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } else if (e.key === 'Escape') { setEditName(categoryName); (e.target as HTMLInputElement).blur(); } }; return (
{canManageChannels ? (
setEditName(e.target.value)} onBlur={handleNameBlur} onKeyDown={handleNameKeyDown} disabled={isSavingName} className="input-standard flex-1 py-1.5 px-2 text-sm" maxLength={100} />
) : (
{categoryName}
)}
{error && (
{error}
)}
Private Category
Only selected members and roles will be able to view channels in this category.
{isPrivate && !isFetching && (
This category is hidden from members without explicit access. Channels inside inherit this restriction unless they explicitly override it.
)} {canManageChannels && (
)}
); } // ─── Main Modal ───────────────────────────────────────────────────────────────── export function CategorySettingsModal() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); const closeModal = useUIStore((s) => s.closeModal); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const categories = useSpaceStore((s) => s.categories); const spaces = useSpaceStore((s) => s.spaces); const spacePermissions = useSpaceStore((s) => s.spacePermissions); const [tab, setTab] = useState<'overview' | 'permissions'>('overview'); const [isPrivate, setIsPrivate] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isFetching, setIsFetching] = useState(true); const [error, setError] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const isOpen = activeModal === 'categorySettings'; const categoryId = modalData?.categoryId as string | undefined; const category = categories.find(c => c.id === categoryId); const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS); const canManageRoles = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES); // Reset state when modal closes useEffect(() => { if (!isOpen) { setShowDeleteConfirm(false); setIsDeleting(false); setTab('overview'); } }, [isOpen]); // Fetch overrides for the private toggle (overview tab) const fetchPrivateState = useCallback(() => { if (!categoryId || !currentSpaceId) return; setIsFetching(true); setError(''); const space = spaces.find(s => s.id === currentSpaceId); const catApi = getApiForOrigin(space?._instanceOrigin ?? ''); catApi.categories.getOverrides(categoryId) .then((data: { targetType: string; targetId: string; allow: string; deny: string }[]) => { // Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied const everyoneOverride = data.find( o => o.targetType === 'role' && o.targetId === currentSpaceId ); if (everyoneOverride) { const denyBits = stringToPermissions(everyoneOverride.deny); setIsPrivate((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n); } else { setIsPrivate(false); } }) .catch((err: Error) => { setError(err.message || 'Failed to load category overrides'); }) .finally(() => { setIsFetching(false); }); }, [categoryId, currentSpaceId, spaces]); useEffect(() => { if (isOpen && categoryId && currentSpaceId) { fetchPrivateState(); } else { setIsFetching(false); } }, [isOpen, categoryId, currentSpaceId, fetchPrivateState]); if (!isOpen || !category || !categoryId || !currentSpaceId) return null; const space = spaces.find(s => s.id === currentSpaceId); const handleToggle = async () => { setError(''); setIsLoading(true); const catApi = getApiForOrigin(space?._instanceOrigin ?? ''); try { if (!isPrivate) { // Make private: deny VIEW_CHANNEL for @everyone role await catApi.categories.putOverride(categoryId, { targetType: 'role', targetId: currentSpaceId, allow: '0', deny: permissionsToString(PermissionBits.VIEW_CHANNEL), }); setIsPrivate(true); } else { // Make public: remove the @everyone VIEW_CHANNEL deny override await catApi.categories.deleteOverride(categoryId, 'role', currentSpaceId); setIsPrivate(false); } // Re-fetch to keep in sync fetchPrivateState(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update category privacy'); } finally { setIsLoading(false); } }; const handleRename = async (name: string) => { setError(''); try { await useSpaceStore.getState().updateCategory(categoryId, { name }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to rename category'); } }; const handleDeleteCategory = async () => { if (!categoryId) return; setIsDeleting(true); try { await useSpaceStore.getState().deleteCategory(categoryId); closeModal(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete category'); setIsDeleting(false); } }; const showTabs = canManageRoles; const tabClass = (t: typeof tab) => `w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${ tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' }`; return ( <> {showTabs ? (
{/* Tabs */}
{/* Content */}
{tab === 'overview' && ( setShowDeleteConfirm(true)} onRename={handleRename} /> )} {tab === 'permissions' && ( { const catApi = getApiForOrigin(space?._instanceOrigin ?? ''); return catApi.categories.getOverrides(categoryId); }} putOverride={(data) => { const catApi = getApiForOrigin(space?._instanceOrigin ?? ''); return catApi.categories.putOverride(categoryId, data); }} deleteOverride={(targetType, targetId) => { const catApi = getApiForOrigin(space?._instanceOrigin ?? ''); return catApi.categories.deleteOverride(categoryId, targetType, targetId); }} /> )}
) : ( setShowDeleteConfirm(true)} onRename={handleRename} /> )}
setShowDeleteConfirm(false)} onConfirm={handleDeleteCategory} title={`Delete "${category.name}"?`} description={<> This will permanently delete the {category.name} category. Channels inside it will be moved to the top level. This action cannot be undone. } confirmLabel="Delete Category" variant="danger" loading={isDeleting} /> ); }