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 = (
{type === 'dm' ? (
B
) : type === 'action' ? (
actionType === 'add' ? (
) : actionType === 'explore' ? (
) : (
)
) : icon ? (
) : (
{firstLetter}
)}
);
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(
{ onRename(); onClose(); }}
>
Rename Folder
Folder Color
{ onColorChange(null); onClose(); }}
title="Default"
/>
{FOLDER_COLORS.map((c) => (
{ onColorChange(c.value); onClose(); }}
title={c.name}
/>
))}
{ onUngroup(); onClose(); }}
>
Ungroup
,
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 (
onDragStart(e, space.id)}
onClick={() => {
onSpaceClick(space.id);
onClose();
}}
onContextMenu={(e) => {
onSpaceContextMenu(space.id, e);
onClose();
}}
>
{/* Space icon */}
{icon ? (
) : (
{space.name.charAt(0).toUpperCase()}
)}
{/* Name */}
{space.name}
{/* Federation badge */}
{isFederated && !isDimmed && (
)}
{/* Unread dot */}
{hasUnread && (
)}
);
})}
,
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(
Invite People
{isOwner ? (
setShowTransferModal(true)}
>
Transfer Ownership
) : (
setShowLeaveConfirm(true)}
>
Leave Space
)}
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.
setSelectedUserId(null)}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
disabled={transferring}
>
Cancel
{transferring ? 'Transferring...' : 'Transfer'}
) : (
/* 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 (
setSelectedUserId(member.userId)}
className="w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] transition-colors"
>
{avatarUrl ? (
) : (
{(member.user.displayName || member.user.username).charAt(0).toUpperCase()}
)}
{member.user.displayName || member.user.username}
{member.user.displayName && (
{member.user.username}
)}
);
})
)}
>
)}
,
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 (
{/* Unified space list (ordered by user layout) */}
{resolvedLayout.map((item) => {
if (item.type === 'space') {
const { isFederated, isDimmed, tooltipText } = getFederationInfo(item.space);
return (
handleSpaceClick(item.space.id)}
onContextMenu={(e) => handleSpaceContextMenu(item.space.id, e)}
hasUnread={unreadSpaceIds.has(item.space.id)}
dimmed={isDimmed}
federationBadge={isFederated}
federationDisconnected={isDimmed}
tooltipText={tooltipText}
draggable
onDragStart={(e) => handleDragStart(e, item.space.id, 'space')}
onDragOver={(e) => handleDragOver(e, item.space.id, 'space')}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
isDragging={dragState?.dragType === 'space' && dragState.dragId === item.space.id}
dropIndicator={dropIndicator?.targetId === item.space.id ? dropIndicator.position : null}
/>
);
}
// Folder — always a single icon slot
const { folder, spaces: folderSpaces } = item;
const isActive = folderHasActive(folderSpaces);
const hasUnread = folderHasUnread(folderSpaces);
const isFlyoutOpen = openFolderId === folder.id;
return (
setOpenFolderId(isFlyoutOpen ? null : folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setFolderContextMenu({ folder, x: e.clientX, y: e.clientY });
}}
onDragStart={(e) => handleDragStart(e, folder.id, 'folder')}
onDragOver={(e) => handleDragOver(e, folder.id, 'folder')}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
anchorRef={(el) => {
if (el) folderAnchorRefs.current.set(folder.id, el);
else folderAnchorRefs.current.delete(folder.id);
}}
/>
);
})}
openModal('createSpace')}
type="action"
actionType="add"
/>
openModal('joinSpace')}
type="action"
actionType="join"
/>
{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)}
/>
)}
{openFolderId && (() => {
const folderItem = resolvedLayout.find(
(i): i is ResolvedItem & { type: 'folder' } =>
i.type === 'folder' && i.folder.id === openFolderId
);
const anchor = folderAnchorRefs.current.get(openFolderId);
if (!folderItem || !anchor) return null;
return (
setOpenFolderId(null)}
onSpaceClick={handleSpaceClick}
onSpaceContextMenu={handleSpaceContextMenu}
onRename={(name) => handleFolderRename(openFolderId, name)}
onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)}
/>
);
})()}
);
}