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 { 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 } from '../../utils/gradients';
import { isElectron } from '../../platform/platform';
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 undefined;
}
// 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 — offset into the mb-1.5 gap so adjacent items share one line */}
{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}
)}
);
}
// ─── FolderFlyout ─────────────────────────────────────────────────────────
function FolderFlyout({
folder,
spaces,
anchorEl,
currentSpaceId,
unreadSpaceIds,
disconnectedOrigins,
renamingFolderId,
onClose,
onSpaceClick,
onSpaceContextMenu,
onRename,
onDragStart,
onReorder,
onParentDragEnd,
}: {
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;
onReorder: (reorderedSpaceIds: string[]) => void;
onParentDragEnd: () => void;
}) {
const anchorRef = useRef(anchorEl);
anchorRef.current = anchorEl;
const floatingRef = useRef(null);
const { style } = useFloatingPosition(anchorRef, floatingRef, {
placement: 'right',
offset: 12,
});
// Intra-folder DnD state
const [flyoutDrop, setFlyoutDrop] = useState<{
targetSpaceId: string;
position: 'before' | 'after';
} | null>(null);
const flyoutDropRef = useRef(flyoutDrop);
flyoutDropRef.current = flyoutDrop;
const handleFlyoutDragOver = useCallback((e: React.DragEvent, targetSpaceId: string) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const relY = e.clientY - rect.top;
const position: 'before' | 'after' = relY < rect.height * 0.5 ? 'before' : 'after';
// Normalize 'before' to previous item's 'after' so a single indicator renders
if (position === 'before') {
const idx = spaces.findIndex(s => s.id === targetSpaceId);
if (idx > 0) {
const prevId = spaces[idx - 1]!.id;
setFlyoutDrop({ targetSpaceId: prevId, position: 'after' });
return;
}
}
setFlyoutDrop({ targetSpaceId, position });
}, [spaces]);
const handleFlyoutDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const indicator = flyoutDropRef.current;
const dragId = e.dataTransfer.getData('text/plain');
if (!indicator || !dragId) {
setFlyoutDrop(null);
onParentDragEnd();
return;
}
// Don't reorder if dropping on self in same position
const currentIds = spaces.map(s => s.id);
const dragIdx = currentIds.indexOf(dragId);
if (dragIdx === -1) {
// Dragged space is not in this folder — let parent handle it
setFlyoutDrop(null);
onParentDragEnd();
return;
}
// Remove dragged space and re-insert at target position
const without = currentIds.filter(id => id !== dragId);
const targetIdx = without.indexOf(indicator.targetSpaceId);
if (targetIdx === -1) {
setFlyoutDrop(null);
onParentDragEnd();
return;
}
const insertIdx = indicator.position === 'before' ? targetIdx : targetIdx + 1;
without.splice(insertIdx, 0, dragId);
onReorder(without);
setFlyoutDrop(null);
onParentDragEnd();
}, [spaces, onReorder, onParentDragEnd]);
const handleFlyoutDragEnd = useCallback(() => {
setFlyoutDrop(null);
onParentDragEnd();
}, [onParentDragEnd]);
// 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) &&
!(e.target as HTMLElement).closest?.('[data-flyout-safe]')
) {
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, idx) => {
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 (
{/* Drop indicator: before first item */}
{idx === 0 && flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'before' && (
)}
{/* Drop indicator: after item */}
{flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'after' && (
)}
);
})}
,
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 — offset into the mb-1.5 gap so adjacent items share one line */}
{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 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);
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