import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import ReactDOM from 'react-dom'; import { useNavigate, useLocation } from 'react-router-dom'; import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore'; 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 { Tooltip } from '../ui/Tooltip'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; // ─── Resolved layout types ───────────────────────────────────────────────── type ResolvedItem = | { type: 'space'; space: TaggedSpace } | { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] }; // ─── Folder color presets ───────────────────────────────────────────────── const FOLDER_COLORS = [ { name: 'mint', value: '#86efac' }, { name: 'peach', value: '#fbbf93' }, { name: 'lavender', value: '#c4b5fd' }, { name: 'sky', value: '#7dd3fc' }, { name: 'amber', value: '#fcd34d' }, { name: 'rose', value: '#fda4af' }, { name: 'coral', value: '#fb7185' }, ]; // ─── SidebarItem ───────────────────────────────────────────────────────── interface SidebarItemProps { id: string; name: string; icon?: string | null; avatarColor?: string | null; active: boolean; onClick: () => void; onContextMenu?: (e: React.MouseEvent) => void; type?: 'space' | 'dm' | 'action'; actionType?: 'add' | 'join' | 'explore'; hasUnread?: boolean; dimmed?: boolean; federationBadge?: boolean; federationDisconnected?: boolean; tooltipText?: string; draggable?: boolean; onDragStart?: (e: React.DragEvent) => void; onDragOver?: (e: React.DragEvent) => void; onDragEnd?: () => void; onDrop?: (e: React.DragEvent) => void; isDragging?: boolean; dropIndicator?: 'before' | 'after' | 'merge' | null; } function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText, draggable, onDragStart, onDragOver, onDragEnd, onDrop, isDragging, dropIndicator }: SidebarItemProps) { const [isHovered, setIsHovered] = useState(false); const firstLetter = name.charAt(0).toUpperCase(); const getPillHeight = () => { if (active) return 'h-8'; if (isHovered) return 'h-4'; if (hasUnread && !active) return 'h-2'; return 'h-2 scale-0'; }; const backgroundStyle = useMemo((): React.CSSProperties | undefined => { if (type === 'action') { return { background: isHovered ? 'rgba(134, 239, 172, 0.12)' : 'rgba(255, 255, 255, 0.04)', }; } if (type === 'dm') { return { background: HOME_GRADIENT.gradient }; } // Space type — if it has a custom icon image, no gradient needed if (icon) return undefined; const spaceGrad = getSpaceGradient(id, name, avatarColor); return { background: spaceGrad.gradient }; }, [type, id, name, icon, avatarColor, isHovered]); const getButtonClasses = () => { const base = 'w-10 h-10 flex items-center justify-center duration-200 overflow-hidden [transition:border-radius_0.2s,background_0.2s,color_0.2s]'; if (type === 'dm') { return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`; } if (type === 'action') { return `${base} rounded-[20px] hover:rounded-[13px] text-accent-mint`; } if (icon) { return `${base} ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`; } return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`; }; const buttonContent = ( ); const innerContent = (
{buttonContent} {federationBadge && (
{federationDisconnected ? (
) : ( )}
)}
); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onContextMenu={onContextMenu} draggable={draggable} onDragStart={onDragStart} onDragOver={onDragOver} onDragEnd={onDragEnd} onDrop={onDrop} > {/* Drop indicator lines */} {dropIndicator === 'before' && (
)} {dropIndicator === 'after' && (
)} {/* Pill Indicator */} {(type === 'space' || type === 'dm') && (
)} {tooltipText ? ( {innerContent} ) : ( innerContent )}
); } // ─── Mini space icon for collapsed folder ────────────────────────────────── function MiniSpaceIcon({ space }: { space: TaggedSpace }) { const icon = space.icon; if (icon) { return ( ); } const grad = getSpaceGradient(space.id, space.name, space.avatarColor); return (
{space.name.charAt(0).toUpperCase()}
); } // ─── Folder icon (2×2 grid with glass-pill) ─────────────────────────────── function FolderIcon({ spaces, color, isActive, isHovered }: { spaces: TaggedSpace[]; color: string | null; isActive: boolean; isHovered: boolean }) { const display = spaces.slice(0, 4); const remaining = spaces.length - 4; const borderColor = color ? `rgba(${parseInt(color.slice(1, 3), 16)}, ${parseInt(color.slice(3, 5), 16)}, ${parseInt(color.slice(5, 7), 16)}, 0.3)` : undefined; return (
{display.map((s) => (
))} {display.length < 4 && Array.from({ length: 4 - display.length }).map((_, i) => (
))}
{remaining > 0 && (
+{remaining}
)}
); } // ─── 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({ folder, spaces, anchorEl, currentSpaceId, unreadSpaceIds, disconnectedOrigins, renamingFolderId, onClose, onSpaceClick, onSpaceContextMenu, onRename, onDragStart, }: { folder: SpaceFolder; spaces: TaggedSpace[]; anchorEl: HTMLDivElement; currentSpaceId: string | null; unreadSpaceIds: Set; disconnectedOrigins: Set; renamingFolderId: string | null; onClose: () => void; onSpaceClick: (spaceId: string) => void; onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void; onRename: (name: string) => void; onDragStart: (e: React.DragEvent, spaceId: string) => void; }) { const anchorRef = useRef(anchorEl); anchorRef.current = anchorEl; const floatingRef = useRef(null); const { style } = useFloatingPosition(anchorRef, floatingRef, { placement: 'right', offset: 12, }); // Close on click-outside and Escape useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( floatingRef.current && !floatingRef.current.contains(e.target as Node) && !anchorEl.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, anchorEl]); const isRenaming = renamingFolderId === folder.id; return ReactDOM.createPortal(
{/* Folder header */} {(folder.name || isRenaming) && (
{isRenaming ? ( onRename(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') onRename(e.currentTarget.value); if (e.key === 'Escape') onClose(); }} /> ) : ( {folder.name} )}
)} {/* Space rows */} {spaces.map((space) => { const isActive = currentSpaceId === space.id; const hasUnread = unreadSpaceIds.has(space.id) && !isActive; const origin = space._instanceOrigin; const isFederated = !!origin; const isDimmed = isFederated && disconnectedOrigins.has(origin); const icon = space.icon; const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null; return ( ); })}
, document.body, ); } // ─── 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, ); } // ─── TransferOwnershipModal ─────────────────────────────────────────────── function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) { const modalRef = useRef(null); const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); const members = useSpaceStore((s) => s.members); const currentUserId = useAuthStore((s) => s.user?.id); const transferOwnership = useSpaceStore((s) => s.transferOwnership); const addToast = useUIStore((s) => s.addToast); const [search, setSearch] = useState(''); const [selectedUserId, setSelectedUserId] = useState(null); const [transferring, setTransferring] = useState(false); // Filter members: exclude self, filter by search const filteredMembers = useMemo(() => { const spaceMembers = members.filter(m => m.userId !== currentUserId); if (!search.trim()) return spaceMembers; const q = search.toLowerCase(); return spaceMembers.filter(m => m.user.displayName?.toLowerCase().includes(q) || m.user.username.toLowerCase().includes(q) ); }, [members, currentUserId, search]); const selectedMember = selectedUserId ? members.find(m => m.userId === selectedUserId) : null; // Close on click-outside and escape useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { onClose(); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (selectedUserId) { setSelectedUserId(null); } else { onClose(); } } }; document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleKeyDown); }; }, [onClose, selectedUserId]); if (!space) return null; const handleTransfer = async () => { if (!selectedUserId) return; setTransferring(true); try { await transferOwnership(spaceId, selectedUserId); addToast(`Ownership transferred to ${selectedMember?.user.displayName || selectedMember?.user.username}`, 'success', 3000); onClose(); } catch (err) { addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000); } finally { setTransferring(false); } }; return ReactDOM.createPortal(
{/* Header */}

Transfer Ownership

Choose a member to become the new owner of {space.name}

{selectedUserId && selectedMember ? ( /* Confirm step */

Transfer ownership of {space.name} to{' '} {selectedMember.user.displayName || selectedMember.user.username}?

You will become a regular member.

) : ( /* Member list */ <>
setSearch(e.target.value)} placeholder="Search members..." className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50" autoFocus />
{filteredMembers.length === 0 ? (

No members found

) : ( filteredMembers.map((member) => { const avatarUrl = member.user.avatar ? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) : null; return ( ); }) )}
)}
, document.body, ); } // ─── FolderSlot (single icon slot for a folder) ────────────────────────── function FolderSlot({ folder, folderSpaces, isActive, hasUnread, isFlyoutOpen, isDragging, dropIndicator, onToggleFlyout, onContextMenu, onDragStart, onDragOver, onDragEnd, onDrop, anchorRef, }: { folder: SpaceFolder; folderSpaces: TaggedSpace[]; isActive: boolean; hasUnread: boolean; isFlyoutOpen: boolean; isDragging: boolean; dropIndicator: 'before' | 'after' | 'merge' | null; onToggleFlyout: () => void; onContextMenu: (e: React.MouseEvent) => void; onDragStart: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void; onDragEnd: () => void; onDrop: (e: React.DragEvent) => void; anchorRef: (el: HTMLDivElement | null) => void; }) { const [isHovered, setIsHovered] = useState(false); const getPillHeight = () => { if (isActive) return 'h-8'; if (isHovered) return 'h-4'; if (hasUnread) return 'h-2'; return 'h-2 scale-0'; }; const iconContent = ( ); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onContextMenu={onContextMenu} draggable onDragStart={onDragStart} onDragOver={onDragOver} onDragEnd={onDragEnd} onDrop={onDrop} > {/* Drop indicators */} {dropIndicator === 'before' && (
)} {dropIndicator === 'after' && (
)} {dropIndicator === 'merge' && (
)} {/* Pill indicator */}
{isFlyoutOpen ? ( iconContent ) : ( {iconContent} )}
); } // ─── SpaceSidebar (main component) ──────────────────────────────────────── export function SpaceSidebar() { const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap); const dmChannels = useSpaceStore((s) => s.dmChannels); const folders = useSpaceStore((s) => s.folders); const spaceLayout = useSpaceStore((s) => s.spaceLayout); const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout); const showDms = useUIStore((s) => s.showDms); const setShowDms = useUIStore((s) => s.setShowDms); const openModal = useUIStore((s) => s.openModal); const addToast = useUIStore((s) => s.addToast); const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight); const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); const unreadChannels = useChatStore((s) => s.unreadChannels); const instances = useInstanceStore((s) => s.instances); const navigate = useNavigate(); const location = useLocation(); // Flyout state 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); const [renamingFolderId, setRenamingFolderId] = useState(null); // DnD state const [dragState, setDragState] = useState<{ dragId: string; dragType: 'space' | 'folder'; sourceFolderId?: string } | null>(null); const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after' | 'merge' } | null>(null); const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ spaceId, x: e.clientX, y: e.clientY }); }, []); const closeContextMenu = useCallback(() => setContextMenu(null), []); // Set of disconnected origins const disconnectedOrigins = useMemo(() => { const set = new Set(); for (const inst of instances) { if (inst.status === 'disconnected' || inst.status === 'error') { set.add(inst.origin); } } return set; }, [instances]); // Build space lookup map const spaceMap = useMemo(() => { const map = new Map(); for (const s of spaces) map.set(s.id, s); return map; }, [spaces]); // Build folder lookup map const folderMap = useMemo(() => { const map = new Map(); for (const f of folders) map.set(f.id, f); return map; }, [folders]); // Reconciled layout: merge spaceLayout with actual spaces and folders const resolvedLayout = useMemo((): ResolvedItem[] => { const memberSpaceIds = new Set(spaces.map(s => s.id)); const result: ResolvedItem[] = []; const accountedSpaceIds = new Set(); if (spaceLayout && spaceLayout.length > 0) { for (const item of spaceLayout) { if (item.t === 's') { const space = spaceMap.get(item.id); if (space) { result.push({ type: 'space', space }); accountedSpaceIds.add(item.id); } } else if (item.t === 'f') { const folder = folderMap.get(item.id); if (folder) { const folderSpaces = folder.spaceIds .map(sid => spaceMap.get(sid)) .filter((s): s is TaggedSpace => !!s); if (folderSpaces.length > 0) { result.push({ type: 'folder', folder, spaces: folderSpaces, }); for (const s of folderSpaces) accountedSpaceIds.add(s.id); } } } } } // Append any spaces not in the layout (newly joined, etc.) for (const space of spaces) { if (!accountedSpaceIds.has(space.id)) { result.push({ type: 'space', space }); } } return result; }, [spaceLayout, spaces, spaceMap, folderMap]); // Compute which spaces have unread channels const unreadSpaceIds = useMemo(() => { const ids = new Set(); for (const channelId of unreadChannels) { const spaceId = channelToSpaceMap.get(channelId); if (spaceId) ids.add(spaceId); } return ids; }, [unreadChannels, channelToSpaceMap]); // Check if any DM channels are unread const hasDmUnread = useMemo(() => { for (const dm of dmChannels) { if (unreadChannels.has(dm.id)) return true; } return false; }, [unreadChannels, dmChannels]); const handleSpaceClick = useCallback((spaceId: string) => { const space = spaceMap.get(spaceId); const origin = space?._instanceOrigin; if (origin && disconnectedOrigins.has(origin)) { const inst = instances.find(i => i.origin === origin); addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000); return; } setCurrentSpace(spaceId); setShowDms(false); navigate(`/channels/${spaceId}`); }, [spaceMap, disconnectedOrigins, instances, addToast, setCurrentSpace, setShowDms, navigate]); const handleDmClick = () => { setShowDms(true); setCurrentSpace(null); setCurrentChannel(null); navigate('/channels/@me'); }; const handleExploreClick = () => { setCurrentSpace(null); setCurrentChannel(null); navigate('/explore'); }; // ─── DnD handlers ────────────────────────────────────────────────────── const handleDragStart = useCallback((e: React.DragEvent, id: string, type: 'space' | 'folder', sourceFolderId?: string) => { e.dataTransfer.setData('text/plain', id); e.dataTransfer.effectAllowed = 'move'; setDragState({ dragId: id, dragType: type, sourceFolderId }); }, []); const handleDragOver = useCallback((e: React.DragEvent, targetId: string, targetType: 'space' | 'folder') => { if (!dragState) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = e.currentTarget.getBoundingClientRect(); const relY = e.clientY - rect.top; const height = rect.height; let position: 'before' | 'after' | 'merge'; if (targetType === 'folder' || dragState.dragType === 'space') { // Space items: top 25% = before, middle 50% = merge, bottom 25% = after if (relY < height * 0.25) { position = 'before'; } else if (relY > height * 0.75) { position = 'after'; } else { // Merge zone: only if dragging a space onto another space or folder if (dragState.dragType === 'space' && dragState.dragId !== targetId) { position = 'merge'; } else { position = relY < height * 0.5 ? 'before' : 'after'; } } } else { // Folder dragging: only before/after, no merge position = relY < height * 0.5 ? 'before' : 'after'; } setDropIndicator({ targetId, position }); }, [dragState]); const handleDragEnd = useCallback(() => { setDragState(null); setDropIndicator(null); }, []); // Build layout items and folder payload from resolvedLayout for persistence const buildLayoutPayload = useCallback((resolved: ResolvedItem[]) => { const items: SpaceLayoutItem[] = []; const folderPayload: Record = {}; for (const item of resolved) { if (item.type === 'space') { items.push({ t: 's', id: item.space.id }); } else { items.push({ t: 'f', id: item.folder.id }); folderPayload[item.folder.id] = { name: item.folder.name, color: item.folder.color, spaceIds: item.spaces.map(s => s.id), }; } } return { items, folderPayload }; }, []); const persistLayout = useCallback((resolved: ResolvedItem[]) => { const { items, folderPayload } = buildLayoutPayload(resolved); updateSpaceLayout(items, folderPayload); }, [buildLayoutPayload, updateSpaceLayout]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); if (!dragState || !dropIndicator) { handleDragEnd(); return; } const { dragId, dragType, sourceFolderId } = dragState; const { targetId, position } = dropIndicator; // Don't drop on self if (dragId === targetId && position !== 'merge') { handleDragEnd(); return; } // Work with a mutable copy of the resolved layout let newLayout = resolvedLayout.map(item => { if (item.type === 'folder') { return { ...item, spaces: [...item.spaces], folder: { ...item.folder } }; } return { ...item }; }) as ResolvedItem[]; if (dragType === 'space') { const dragSpace = spaceMap.get(dragId); if (!dragSpace) { handleDragEnd(); return; } // Remove from source if (sourceFolderId) { // Remove from folder const folderItem = newLayout.find(i => i.type === 'folder' && i.folder.id === sourceFolderId) as (ResolvedItem & { type: 'folder' }) | undefined; if (folderItem) { folderItem.spaces = folderItem.spaces.filter(s => s.id !== dragId); folderItem.folder = { ...folderItem.folder, spaceIds: folderItem.spaces.map(s => s.id) }; } } else { // Remove standalone newLayout = newLayout.filter(item => !(item.type === 'space' && item.space.id === dragId)); } if (position === 'merge') { // Find the target const targetIdx = newLayout.findIndex(item => (item.type === 'space' && item.space.id === targetId) || (item.type === 'folder' && item.folder.id === targetId) ); if (targetIdx === -1) { handleDragEnd(); return; } const targetItem = newLayout[targetIdx]; if (!targetItem) { handleDragEnd(); return; } if (targetItem.type === 'space') { // Create new folder with both spaces const tempId = `new:${Date.now()}`; const newFolder: ResolvedItem = { type: 'folder', folder: { id: tempId, userId: '', name: null, color: null, position: 0, spaceIds: [targetItem.space.id, dragSpace.id], }, spaces: [targetItem.space, dragSpace], }; newLayout[targetIdx] = newFolder; } else if (targetItem.type === 'folder') { // Add to existing folder targetItem.spaces.push(dragSpace); targetItem.folder = { ...targetItem.folder, spaceIds: targetItem.spaces.map(s => s.id) }; } } else { // Reorder: insert before or after target const targetIdx = newLayout.findIndex(item => (item.type === 'space' && item.space.id === targetId) || (item.type === 'folder' && item.folder.id === targetId) ); if (targetIdx === -1) { handleDragEnd(); return; } const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; const newItem: ResolvedItem = { type: 'space', space: dragSpace }; newLayout.splice(insertIdx, 0, newItem); } // Dissolve folders with < 2 members newLayout = newLayout.flatMap(item => { if (item.type === 'folder' && item.spaces.length < 2) { if (item.spaces.length === 1 && item.spaces[0]) { return [{ type: 'space' as const, space: item.spaces[0] }]; } return []; // 0 members, remove entirely } return [item]; }); } else if (dragType === 'folder') { // Remove the folder from its current position const dragIdx = newLayout.findIndex(i => i.type === 'folder' && i.folder.id === dragId); if (dragIdx === -1) { handleDragEnd(); return; } const dragItem = newLayout.splice(dragIdx, 1)[0]; if (!dragItem) { handleDragEnd(); return; } // Insert at target position const targetIdx = newLayout.findIndex(item => (item.type === 'space' && item.space.id === targetId) || (item.type === 'folder' && item.folder.id === targetId) ); if (targetIdx === -1) { newLayout.push(dragItem); } else { const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; newLayout.splice(insertIdx, 0, dragItem); } } persistLayout(newLayout); handleDragEnd(); }, [dragState, dropIndicator, resolvedLayout, spaceMap, handleDragEnd, persistLayout]); // ─── Folder actions ────────────────────────────────────────────────── const handleFolderRename = useCallback((folderId: string, name: string) => { const newLayout = resolvedLayout.map(item => { if (item.type === 'folder' && item.folder.id === folderId) { return { ...item, folder: { ...item.folder, name: name.trim() || null } }; } return item; }); persistLayout(newLayout); setRenamingFolderId(null); }, [resolvedLayout, persistLayout]); const handleFolderColorChange = useCallback((folderId: string, color: string | null) => { const newLayout = resolvedLayout.map(item => { if (item.type === 'folder' && item.folder.id === folderId) { return { ...item, folder: { ...item.folder, color } }; } return item; }); persistLayout(newLayout); }, [resolvedLayout, persistLayout]); const handleUngroup = useCallback((folderId: string) => { setOpenFolderId(null); const newLayout = resolvedLayout.flatMap(item => { if (item.type === 'folder' && item.folder.id === folderId) { return item.spaces.map(s => ({ type: 'space' as const, space: s })); } return [item]; }); persistLayout(newLayout); }, [resolvedLayout, persistLayout]); // ─── Helpers for rendering ────────────────────────────────────────── const getFederationInfo = useCallback((space: TaggedSpace) => { const origin = space._instanceOrigin; const isFederated = !!origin; const isDimmed = isFederated && disconnectedOrigins.has(origin); let tooltipText = space.name; if (isFederated) { try { const hostLabel = new URL(origin).host; tooltipText = `${space.name} \u00b7 ${hostLabel}`; } catch { /* ignore */ } } return { isFederated, isDimmed, tooltipText }; }, [disconnectedOrigins]); // Check if a folder has any unread spaces const folderHasUnread = useCallback((folderSpaces: TaggedSpace[]) => { return folderSpaces.some(s => unreadSpaceIds.has(s.id)); }, [unreadSpaceIds]); // Check if folder has any active space const folderHasActive = useCallback((folderSpaces: TaggedSpace[]) => { return currentSpaceId ? folderSpaces.some(s => s.id === currentSpaceId) : false; }, [currentSpaceId]); // Auto-close flyout when its folder dissolves useEffect(() => { if (openFolderId && !resolvedLayout.some(i => i.type === 'folder' && i.folder.id === openFolderId)) { setOpenFolderId(null); } }, [openFolderId, resolvedLayout]); return ( ); }