diff --git a/packages/web/src/components/modals/CategorySettingsModal.tsx b/packages/web/src/components/modals/CategorySettingsModal.tsx new file mode 100644 index 00000000..8589a565 --- /dev/null +++ b/packages/web/src/components/modals/CategorySettingsModal.tsx @@ -0,0 +1,387 @@ +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} + /> + + ); +}