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 (
Category
{canManageChannels ? (
) : (
)}
{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 && (
Danger Zone
Delete Category
)}
);
}
// ─── 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 */}
setTab('overview')} className={tabClass('overview')}>
Overview
setTab('permissions')} className={tabClass('permissions')}>
Permissions
{/* 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}
/>
>
);
}