diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index eaf1da38..60157a2d 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -16,7 +16,7 @@ import { AudioManager } from '../../audio/AudioManager'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { parseFederatedUsername, isSelf } from '../../utils/identity'; import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; -import { ContextMenu } from '../ui/ContextMenu'; +import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { DmSearchBar } from './DmSearchBar'; @@ -108,10 +108,8 @@ export function ChannelSidebar() { const [deleteCategoryId, setDeleteCategoryId] = useState(null); const [deleteCategoryLoading, setDeleteCategoryLoading] = useState(false); - // Sidebar background context menu state - const [sidebarMenuOpen, setSidebarMenuOpen] = useState(false); - const [sidebarMenuPos, setSidebarMenuPos] = useState({ x: 0, y: 0 }); - const sidebarMenuRef = useRef(null); + // Centralized context menu + const openContextMenu = useContextMenuStore((s) => s.open); // Collapse state — persisted in localStorage const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`; @@ -162,35 +160,86 @@ export function ChannelSidebar() { return chs.some(ch => unreadChannels.has(ch.id)); }, [channelsByCategory, unreadChannels]); - // Sidebar background context menu: close on click/scroll, viewport clamp - useEffect(() => { - if (!sidebarMenuOpen) return; - const close = () => setSidebarMenuOpen(false); - document.addEventListener('click', close); - document.addEventListener('scroll', close, true); - return () => { - document.removeEventListener('click', close); - document.removeEventListener('scroll', close, true); - }; - }, [sidebarMenuOpen]); - - useEffect(() => { - if (sidebarMenuOpen && sidebarMenuRef.current) { - const rect = sidebarMenuRef.current.getBoundingClientRect(); - const pos = { ...sidebarMenuPos }; - if (rect.right > window.innerWidth) pos.x = window.innerWidth - rect.width - 8; - if (rect.bottom > window.innerHeight) pos.y = window.innerHeight - rect.height - 8; - if (pos.x < 8) pos.x = 8; - if (pos.y < 8) pos.y = 8; - if (pos.x !== sidebarMenuPos.x || pos.y !== sidebarMenuPos.y) setSidebarMenuPos(pos); - } - }, [sidebarMenuOpen, sidebarMenuPos]); - const handleSidebarContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); - setSidebarMenuPos({ x: e.clientX, y: e.clientY }); - setSidebarMenuOpen(true); - }, []); + e.stopPropagation(); + const items: ContextMenuItem[] = []; + if (canManageChannels) { + items.push({ + key: 'create-channel', + type: 'action', + label: 'Create Channel', + icon: ( + + + + + ), + onClick: () => openModal('createChannel'), + }); + items.push({ + key: 'create-category', + type: 'action', + label: 'Create Category', + icon: ( + + + + ), + onClick: () => openModal('createCategory'), + }); + } + if (canCreateInvite) { + items.push({ + key: 'invite', + type: 'action', + label: 'Invite People', + icon: ( + + + + ), + onClick: () => openModal('invite'), + }); + } + items.push({ + key: 'settings', + type: 'action', + label: 'Space Settings', + icon: ( + + + + ), + onClick: () => openModal('spaceSettings'), + }); + openContextMenu({ x: e.clientX, y: e.clientY }, items); + }, [canManageChannels, canCreateInvite, openModal, openContextMenu]); + + const handleDmContextMenu = useCallback((e: React.MouseEvent, dmId: string) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu({ x: e.clientX, y: e.clientY }, [ + { + key: 'leave-group', + type: 'action', + label: 'Leave Group', + danger: true, + icon: ( + + + + ), + onClick: () => { + if (currentChannelId === dmId) { + navigate('/channels/@me'); + setCurrentChannel(null); + } + useSpaceStore.getState().leaveDm(dmId); + }, + }, + ]); + }, [openContextMenu, currentChannelId, navigate, setCurrentChannel]); // DnD handlers const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => { @@ -537,29 +586,9 @@ export function ChannelSidebar() { if (isGroup) { return ( - - - - ), - onClick: () => { - if (currentChannelId === dm.id) { - navigate('/channels/@me'); - setCurrentChannel(null); - } - useSpaceStore.getState().leaveDm(dm.id); - }, - }, - ]} - > +
handleDmContextMenu(e, dm.id)}> {dmItem} - +
); } @@ -706,9 +735,13 @@ export function ChannelSidebar() {
{/* Category header */} {canManageChannels ? ( - { + e.preventDefault(); + e.stopPropagation(); + openContextMenu({ x: e.clientX, y: e.clientY }, [ { + key: 'delete-category', + type: 'action', label: 'Delete Category', danger: true, icon: ( @@ -718,10 +751,10 @@ export function ChannelSidebar() { ), onClick: () => setDeleteCategoryId(category.id), }, - ]} - > + ]); + }}> {categoryHeader} - +
) : categoryHeader} {/* Category channels (hidden when collapsed, unless active) */} @@ -791,70 +824,6 @@ export function ChannelSidebar() { {floatingPanel} - {/* Sidebar background context menu */} - {sidebarMenuOpen && ( -
- {canManageChannels && ( - - )} - {canManageChannels && ( - - )} - {canCreateInvite && ( - - )} - -
- )} setDeleteCategoryId(null)}