From cba19e8b20b1fbeef40f055b762f495f186d616f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:41:17 +0100 Subject: [PATCH] refactor: migrate SpaceSidebar context menus to centralized store --- .../src/components/layout/SpaceSidebar.tsx | 390 +++++++----------- 1 file changed, 148 insertions(+), 242 deletions(-) diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 7e3be1c3..4b876037 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -6,13 +6,13 @@ import type { TaggedSpace } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useInstanceStore } from '../../stores/instanceStore'; -import { useAuthStore } from '../../stores/authStore'; +import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { Tooltip } from '../ui/Tooltip'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; -import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; +import { getSpaceGradient } from '../../utils/gradients'; import { isElectron } from '../../platform/platform'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; @@ -250,88 +250,6 @@ function FolderIcon({ spaces, color, isActive, isHovered }: { spaces: TaggedSpac ); } -// ─── Folder context menu ────────────────────────────────────────────────── - -function FolderContextMenu({ folder, x, y, onClose, onRename, onColorChange, onUngroup }: { - folder: SpaceFolder; - x: number; - y: number; - onClose: () => void; - onRename: () => void; - onColorChange: (color: string | null) => void; - onUngroup: () => void; -}) { - const menuRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose]); - - const menuWidth = 200; - const menuHeight = 140; - const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); - const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); - - return ReactDOM.createPortal( -
- -
-

Folder Color

-
-
-
-
- -
, - document.body, - ); -} - // ─── FolderFlyout ───────────────────────────────────────────────────────── function FolderFlyout({ @@ -575,136 +493,6 @@ function FolderFlyout({ ); } -// ─── SpaceContextMenu ───────────────────────────────────────────────────── - -function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) { - const menuRef = useRef(null); - const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); - const leaveSpace = useSpaceStore((s) => s.leaveSpace); - const generateInvite = useSpaceStore((s) => s.generateInvite); - const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); - const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); - const setShowDms = useUIStore((s) => s.setShowDms); - const addToast = useUIStore((s) => s.addToast); - const navigate = useNavigate(); - const [showTransferModal, setShowTransferModal] = useState(false); - const [showLeaveConfirm, setShowLeaveConfirm] = useState(false); - - const isOwner = space?.ownerId === getMyUserIdForOrigin((space as any)?._instanceOrigin ?? ''); - - // Close on click-outside and scroll - useEffect(() => { - if (showTransferModal || showLeaveConfirm) return; // Don't close when sub-dialog is open - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - const handleScroll = () => onClose(); - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('scroll', handleScroll, true); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('scroll', handleScroll, true); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose, showTransferModal, showLeaveConfirm]); - - if (!space) return null; - - const handleInvite = async () => { - try { - const code = await generateInvite(spaceId); - const origin = (space as any)._instanceOrigin || window.location.origin; - const url = `${origin}/invite/${code}`; - await navigator.clipboard.writeText(url); - addToast('Invite link copied to clipboard', 'success', 3000); - } catch { - addToast('Failed to generate invite', 'warning', 3000); - } - onClose(); - }; - - if (showTransferModal) { - return ( - - ); - } - - // Viewport-aware clamping — always 2 items (Invite + Transfer or Invite + Leave) - const menuWidth = 200; - const menuHeight = 2 * 32 + 8; - const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); - const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); - - return ReactDOM.createPortal( -
- - {isOwner ? ( - - ) : ( - - )} - setShowLeaveConfirm(false)} - onConfirm={() => { - if (currentSpaceId === spaceId) { - navigate('/channels/@me'); - setCurrentSpace(null); - setShowDms(true); - } - leaveSpace(spaceId); - setShowLeaveConfirm(false); - onClose(); - }} - title={`Leave ${space.name}`} - description="Are you sure you want to leave this space? You'll need a new invite to rejoin." - variant="danger" - confirmLabel="Leave" - /> -
, - document.body, - ); -} - // ─── FolderSlot (single icon slot for a folder) ────────────────────────── function FolderSlot({ @@ -806,6 +594,8 @@ export function SpaceSidebar() { const folders = useSpaceStore((s) => s.folders); const spaceLayout = useSpaceStore((s) => s.spaceLayout); const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout); + const generateInvite = useSpaceStore((s) => s.generateInvite); + const leaveSpace = useSpaceStore((s) => s.leaveSpace); const showDms = useUIStore((s) => s.showDms); const setShowDms = useUIStore((s) => s.setShowDms); const openModal = useUIStore((s) => s.openModal); @@ -821,9 +611,9 @@ export function SpaceSidebar() { const [openFolderId, setOpenFolderId] = useState(null); const folderAnchorRefs = useRef>(new Map()); - // Context menus - const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null); - const [folderContextMenu, setFolderContextMenu] = useState<{ folder: SpaceFolder; x: number; y: number } | null>(null); + // Modal state for context menu actions that spawn modals + const [transferModalSpaceId, setTransferModalSpaceId] = useState(null); + const [leaveConfirmSpaceId, setLeaveConfirmSpaceId] = useState(null); const [renamingFolderId, setRenamingFolderId] = useState(null); // DnD state @@ -831,12 +621,65 @@ export function SpaceSidebar() { const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after' | 'merge' } | null>(null); const dropIndicatorRef = useRef(dropIndicator); + const openContextMenu = useContextMenuStore((s) => s.open); + const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => { e.preventDefault(); - setContextMenu({ spaceId, x: e.clientX, y: e.clientY }); - }, []); + const space = useSpaceStore.getState().spaces.find(sp => sp.id === spaceId); + if (!space) return; + const isOwner = space.ownerId === getMyUserIdForOrigin((space as TaggedSpace)._instanceOrigin ?? ''); - const closeContextMenu = useCallback(() => setContextMenu(null), []); + const items: ContextMenuItem[] = [ + { + key: 'invite', + type: 'action', + label: 'Invite People', + icon: ( + + + + ), + onClick: async () => { + try { + const code = await useSpaceStore.getState().generateInvite(spaceId); + const origin = (space as TaggedSpace)._instanceOrigin || window.location.origin; + const url = `${origin}/invite/${code}`; + await navigator.clipboard.writeText(url); + useUIStore.getState().addToast('Invite link copied to clipboard', 'success', 3000); + } catch { + useUIStore.getState().addToast('Failed to generate invite', 'warning', 3000); + } + }, + }, + { + key: 'transfer', + type: 'action', + label: 'Transfer Ownership', + hidden: !isOwner, + icon: ( + + + + ), + onClick: () => setTransferModalSpaceId(spaceId), + }, + { + key: 'leave', + type: 'action', + label: 'Leave Space', + hidden: isOwner, + danger: true, + icon: ( + + + + ), + onClick: () => setLeaveConfirmSpaceId(spaceId), + }, + ]; + + openContextMenu({ x: e.clientX, y: e.clientY }, items); + }, [openContextMenu]); // Set of disconnected origins const disconnectedOrigins = useMemo(() => { @@ -1309,7 +1152,65 @@ export function SpaceSidebar() { onToggleFlyout={() => setOpenFolderId(isFlyoutOpen ? null : folder.id)} onContextMenu={(e) => { e.preventDefault(); - setFolderContextMenu({ folder, x: e.clientX, y: e.clientY }); + const folderRef = folder; + const items: ContextMenuItem[] = [ + { + key: 'rename', + type: 'action', + label: 'Rename Folder', + icon: ( + + + + ), + onClick: () => { + setRenamingFolderId(folderRef.id); + setOpenFolderId(folderRef.id); + }, + }, + { + key: 'color', + type: 'custom', + render: () => ( +
+

Folder Color

+
+
+
+ ), + }, + { + key: 'sep', + type: 'separator', + }, + { + key: 'ungroup', + type: 'action', + label: 'Ungroup', + danger: true, + icon: ( + + + + ), + onClick: () => handleUngroup(folderRef.id), + }, + ]; + openContextMenu({ x: e.clientX, y: e.clientY }, items); }} onDragStart={(e) => handleDragStart(e, folder.id, 'folder')} onDragOver={(e) => handleDragOver(e, folder.id, 'folder')} @@ -1352,29 +1253,34 @@ export function SpaceSidebar() { actionType="explore" /> - {contextMenu && ( - - )} - - {folderContextMenu && ( - setFolderContextMenu(null)} - onRename={() => { - setRenamingFolderId(folderContextMenu.folder.id); - setOpenFolderId(folderContextMenu.folder.id); - }} - onColorChange={(color) => handleFolderColorChange(folderContextMenu.folder.id, color)} - onUngroup={() => handleUngroup(folderContextMenu.folder.id)} + {transferModalSpaceId && ( + setTransferModalSpaceId(null)} /> )} + {leaveConfirmSpaceId && (() => { + const space = spaces.find(s => s.id === leaveConfirmSpaceId); + return ( + setLeaveConfirmSpaceId(null)} + onConfirm={() => { + if (currentSpaceId === leaveConfirmSpaceId) { + navigate('/channels/@me'); + setCurrentSpace(null); + setShowDms(true); + } + leaveSpace(leaveConfirmSpaceId); + setLeaveConfirmSpaceId(null); + }} + title={`Leave ${space?.name ?? 'Space'}`} + description="Are you sure you want to leave this space? You'll need a new invite to rejoin." + variant="danger" + confirmLabel="Leave" + /> + ); + })()} {openFolderId && (() => { const folderItem = resolvedLayout.find(