diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 51c4ced8..e125e1f5 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -19,7 +19,7 @@ import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } fro import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { DmSearchBar } from './DmSearchBar'; -import { useDragManager, type DropTarget } from '../../hooks/useDragManager'; +import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -157,6 +157,26 @@ export function ChannelSidebar() { }, [channelsByCategory, unreadChannels]); // --- Centralized drag-and-drop --- + + // Flat ordered list matching visual sidebar order — used by useDragManager + // to normalize 'before B' into 'after A' for a single drop indicator line + const orderedItems = useMemo(() => { + const items: LayoutItem[] = []; + for (const ch of uncategorizedChannels) { + items.push({ id: ch.id, type: 'channel' }); + } + for (const cat of sortedCategories) { + items.push({ id: cat.id, type: 'category' }); + const catChs = channelsByCategory.get(cat.id) ?? []; + if (!collapsedCategories.has(cat.id)) { + for (const ch of catChs) { + items.push({ id: ch.id, type: 'channel' }); + } + } + } + return items; + }, [uncategorizedChannels, sortedCategories, channelsByCategory, collapsedCategories]); + const canMoveMembers = hasPermissionBit(mySpacePerms, PermissionBits.MOVE_MEMBERS); const handleChannelDrop = useCallback((dragId: string, target: DropTarget) => { @@ -275,6 +295,7 @@ export function ChannelSidebar() { scrollContainerRef, canManage: canManageChannels, canMoveMembers, + orderedItems, onChannelDrop: handleChannelDrop, onCategoryDrop: handleCategoryDrop, onVoiceUserDrop: handleVoiceUserDrop, @@ -1257,7 +1278,7 @@ function ChannelItem({ className={`relative ${isDragging ? 'opacity-50' : ''}`} {...channelDragHandlers} > - {dropIndicator === 'before' &&
} + {dropIndicator === 'before' &&
} - {dropIndicator === 'after' &&
} + {dropIndicator === 'after' &&
}
); } @@ -1278,7 +1299,7 @@ function ChannelItem({ className={`relative ${isDragging ? 'opacity-50' : ''}`} {...channelDragHandlers} > - {dropIndicator === 'before' &&
} + {dropIndicator === 'before' &&
} - {dropIndicator === 'after' &&
} + {dropIndicator === 'after' &&
}
); } diff --git a/packages/web/src/hooks/useDragManager.ts b/packages/web/src/hooks/useDragManager.ts index 4fbb06a2..8e287e4d 100644 --- a/packages/web/src/hooks/useDragManager.ts +++ b/packages/web/src/hooks/useDragManager.ts @@ -14,17 +14,25 @@ export interface DropTarget { targetType: 'channel' | 'category'; } +export interface LayoutItem { + id: string; + type: 'channel' | 'category'; +} + interface UseDragManagerOpts { scrollContainerRef: RefObject; canManage: boolean; canMoveMembers: boolean; + /** Flat ordered list of all visible items in sidebar order — used to normalize + * 'before B' into 'after A' so only a single drop indicator line renders. */ + orderedItems: LayoutItem[]; onChannelDrop: (dragId: string, target: DropTarget) => void; onCategoryDrop: (dragId: string, target: DropTarget) => void; onVoiceUserDrop: (userId: string, fromChannelId: string, toChannelId: string) => void; } export function useDragManager(opts: UseDragManagerOpts) { - const { scrollContainerRef, canManage, canMoveMembers, onChannelDrop, onCategoryDrop, onVoiceUserDrop } = opts; + const { scrollContainerRef, canManage, canMoveMembers, orderedItems, onChannelDrop, onCategoryDrop, onVoiceUserDrop } = opts; const [activeDrag, setActiveDrag] = useState(null); const [dropTarget, setDropTarget] = useState(null); @@ -58,9 +66,24 @@ export function useDragManager(opts: UseDragManagerOpts) { e.dataTransfer.dropEffect = 'move'; const rect = e.currentTarget.getBoundingClientRect(); const midY = rect.top + rect.height / 2; - const position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after'; - setDropTarget({ targetId, position, targetType }); - }, [activeDrag]); + let position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after'; + + // Normalize 'before' to previous item's 'after' so a single drop indicator + // line renders between items, eliminating the double-line visual glitch + let resolvedId = targetId; + let resolvedType = targetType; + if (position === 'before') { + const idx = orderedItems.findIndex(item => item.id === targetId); + if (idx > 0) { + const prev = orderedItems[idx - 1]!; + resolvedId = prev.id; + resolvedType = prev.type; + position = 'after'; + } + } + + setDropTarget({ targetId: resolvedId, position, targetType: resolvedType }); + }, [activeDrag, orderedItems]); const handleDragEnd = useCallback(() => { clearState();