From 999c13cdabeec257a1b0a5afadf11165945d7b15 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:43:41 +0100 Subject: [PATCH] refactor: replace old drag state with useDragManager hook in ChannelSidebar --- .../src/components/layout/ChannelSidebar.tsx | 291 ++++++++---------- 1 file changed, 126 insertions(+), 165 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 60157a2d..64b0cee1 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -19,6 +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'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -43,8 +44,6 @@ export function ChannelSidebar() { const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); - // Drag-and-drop state for moving users between voice channels - const [voiceDragState, setVoiceDragState] = useState<{ userId: string; fromChannelId: string } | null>(null); const isSpaceMuted = !!(myOriginId && spaceId && spaceMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isSpaceDeafened = !!(myOriginId && spaceId && spaceDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`)); @@ -52,6 +51,7 @@ export function ChannelSidebar() { const location = useLocation(); const [floatingPanelEl, setFloatingPanelEl] = useState(null); + const scrollContainerRef = useRef(null); const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight); const setFloatingPanelHeight = useUIStore((s) => s.setFloatingPanelHeight); @@ -100,10 +100,6 @@ export function ChannelSidebar() { const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE); const categories = useSpaceStore((s) => s.categories); - // Drag state for channel/category reordering - const [channelDragState, setChannelDragState] = useState<{ dragType: 'channel' | 'category'; dragId: string } | null>(null); - const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after'; type: 'channel' | 'category' } | null>(null); - // Delete category confirmation state const [deleteCategoryId, setDeleteCategoryId] = useState(null); const [deleteCategoryLoading, setDeleteCategoryLoading] = useState(false); @@ -160,6 +156,130 @@ export function ChannelSidebar() { return chs.some(ch => unreadChannels.has(ch.id)); }, [channelsByCategory, unreadChannels]); + // --- Centralized drag-and-drop --- + const canMoveMembers = hasPermissionBit(mySpacePerms, PermissionBits.MOVE_MEMBERS); + + const handleChannelDrop = useCallback((dragId: string, target: DropTarget) => { + if (!currentSpaceId) return; + + const allChannelsCopy = channels.map(ch => ({ + id: ch.id, + position: ch.position, + categoryId: ch.categoryId, + })); + + if (target.targetType === 'channel') { + const targetCh = channels.find(c => c.id === target.targetId); + if (targetCh) { + const dragCh = allChannelsCopy.find(c => c.id === dragId); + if (dragCh) dragCh.categoryId = targetCh.categoryId; + } + } else if (target.targetType === 'category') { + const dragCh = allChannelsCopy.find(c => c.id === dragId); + if (dragCh) dragCh.categoryId = target.targetId; + } + + const grouped = new Map(); + for (const ch of allChannelsCopy) { + let arr = grouped.get(ch.categoryId); + if (!arr) { arr = []; grouped.set(ch.categoryId, arr); } + arr.push(ch); + } + + for (const [, arr] of grouped) { + arr.sort((a, b) => a.position - b.position); + const dragIdx = arr.findIndex(c => c.id === dragId); + if (dragIdx === -1) continue; + const dragItem = arr[dragIdx]!; + arr.splice(dragIdx, 1); + + if (target.targetType === 'channel') { + const targetIdx = arr.findIndex(c => c.id === target.targetId); + if (targetIdx !== -1) { + const insertIdx = target.position === 'before' ? targetIdx : targetIdx + 1; + arr.splice(insertIdx, 0, dragItem); + } else { + arr.push(dragItem); + } + } else { + arr.unshift(dragItem); + } + arr.forEach((ch, i) => { ch.position = i; }); + } + + const channelUpdates = allChannelsCopy.map(ch => ({ + id: ch.id, + position: ch.position, + categoryId: ch.categoryId, + })); + const categoryUpdates = sortedCategories.map(c => ({ + id: c.id, + position: c.position, + })); + + useSpaceStore.getState().setChannels( + channels.map(ch => { + const update = channelUpdates.find(u => u.id === ch.id); + if (update) return { ...ch, position: update.position, categoryId: update.categoryId }; + return ch; + }).sort((a, b) => a.position - b.position) + ); + useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: categoryUpdates }); + }, [channels, sortedCategories, currentSpaceId]); + + const handleCategoryDrop = useCallback((dragId: string, target: DropTarget) => { + if (!currentSpaceId) return; + + const catsCopy = sortedCategories.map(c => ({ id: c.id, position: c.position })); + const dragIdx = catsCopy.findIndex(c => c.id === dragId); + if (dragIdx === -1) return; + + const dragItem = catsCopy[dragIdx]!; + catsCopy.splice(dragIdx, 1); + const targetIdx = catsCopy.findIndex(c => c.id === target.targetId); + if (targetIdx !== -1) { + const insertIdx = target.position === 'before' ? targetIdx : targetIdx + 1; + catsCopy.splice(insertIdx, 0, dragItem); + } else { + catsCopy.push(dragItem); + } + catsCopy.forEach((c, i) => { c.position = i; }); + + const channelUpdates = channels.map(ch => ({ + id: ch.id, + position: ch.position, + categoryId: ch.categoryId, + })); + + useSpaceStore.getState().setCategories( + categories.map(cat => { + const update = catsCopy.find(u => u.id === cat.id); + if (update) return { ...cat, position: update.position }; + return cat; + }).sort((a, b) => a.position - b.position) + ); + useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: catsCopy }); + }, [sortedCategories, categories, channels, currentSpaceId]); + + const handleVoiceUserDrop = useCallback((userId: string, fromChannelId: string, toChannelId: string) => { + const voiceOrigin = getChannelOrigin(fromChannelId); + wsSend({ type: 'voice_move', userId, targetChannelId: toChannelId }, voiceOrigin); + }, []); + + const { + activeDrag, dropTarget, + channelHandlers, categoryHandlers, + voiceUserHandlers, voiceChannelDropZone, + containerHandlers, + } = useDragManager({ + scrollContainerRef, + canManage: canManageChannels, + canMoveMembers, + onChannelDrop: handleChannelDrop, + onCategoryDrop: handleCategoryDrop, + onVoiceUserDrop: handleVoiceUserDrop, + }); + const handleSidebarContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -241,165 +361,6 @@ export function ChannelSidebar() { ]); }, [openContextMenu, currentChannelId, navigate, setCurrentChannel]); - // DnD handlers - const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => { - if (!canManageChannels) return; - e.dataTransfer.setData('application/x-channel-id', channelId); - e.dataTransfer.effectAllowed = 'move'; - setChannelDragState({ dragType: 'channel', dragId: channelId }); - }, [canManageChannels]); - - const handleCategoryDragStart = useCallback((e: React.DragEvent, categoryId: string) => { - if (!canManageChannels) return; - e.dataTransfer.setData('application/x-category-id', categoryId); - e.dataTransfer.effectAllowed = 'move'; - setChannelDragState({ dragType: 'category', dragId: categoryId }); - }, [canManageChannels]); - - const handleDragEnd = useCallback(() => { - setChannelDragState(null); - setDropIndicator(null); - }, []); - - const handleChannelDragOver = useCallback((e: React.DragEvent, targetId: string, type: 'channel' | 'category') => { - if (!channelDragState) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - const rect = e.currentTarget.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - const position = e.clientY < midY ? 'before' : 'after'; - setDropIndicator({ targetId, position, type }); - }, [channelDragState]); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - if (!channelDragState || !currentSpaceId || !dropIndicator) { - setChannelDragState(null); - setDropIndicator(null); - return; - } - - const dragChannelId = e.dataTransfer.getData('application/x-channel-id'); - const dragCategoryId = e.dataTransfer.getData('application/x-category-id'); - - if (dragChannelId && dropIndicator) { - // Channel drag — compute new layout - const allChannelsCopy = channels.map(ch => ({ - id: ch.id, - position: ch.position, - categoryId: ch.categoryId, - })); - - if (dropIndicator.type === 'channel') { - // Dropping on a channel — take its categoryId and insert near it - const targetCh = channels.find(c => c.id === dropIndicator.targetId); - if (targetCh) { - const dragCh = allChannelsCopy.find(c => c.id === dragChannelId); - if (dragCh) { - dragCh.categoryId = targetCh.categoryId; - } - } - } else if (dropIndicator.type === 'category') { - // Dropping on a category header — move channel into that category - const dragCh = allChannelsCopy.find(c => c.id === dragChannelId); - if (dragCh) { - dragCh.categoryId = dropIndicator.targetId; - } - } - - // Recalculate positions: group by category and assign sequential positions - const grouped = new Map(); - for (const ch of allChannelsCopy) { - const key = ch.categoryId; - let arr = grouped.get(key); - if (!arr) { arr = []; grouped.set(key, arr); } - arr.push(ch); - } - - // Within each group, move dragged channel to correct position - for (const [, arr] of grouped) { - arr.sort((a, b) => a.position - b.position); - const dragIdx = arr.findIndex(c => c.id === dragChannelId); - if (dragIdx === -1) continue; - const dragItem = arr[dragIdx]!; - arr.splice(dragIdx, 1); - - // Find target position - if (dropIndicator.type === 'channel') { - const targetIdx = arr.findIndex(c => c.id === dropIndicator.targetId); - if (targetIdx !== -1) { - const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1; - arr.splice(insertIdx, 0, dragItem); - } else { - arr.push(dragItem); - } - } else { - // Dropped on category header — add at start - arr.unshift(dragItem); - } - // Reassign positions - arr.forEach((ch, i) => { ch.position = i; }); - } - - const channelUpdates = allChannelsCopy.map(ch => ({ - id: ch.id, - position: ch.position, - categoryId: ch.categoryId, - })); - const categoryUpdates = sortedCategories.map(c => ({ - id: c.id, - position: c.position, - })); - - // Optimistic update - useSpaceStore.getState().setChannels( - channels.map(ch => { - const update = channelUpdates.find(u => u.id === ch.id); - if (update) return { ...ch, position: update.position, categoryId: update.categoryId }; - return ch; - }).sort((a, b) => a.position - b.position) - ); - - useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: categoryUpdates }); - } else if (dragCategoryId && dropIndicator?.type === 'category') { - // Category drag — reorder categories - const catsCopy = sortedCategories.map(c => ({ id: c.id, position: c.position })); - const dragIdx = catsCopy.findIndex(c => c.id === dragCategoryId); - if (dragIdx !== -1) { - const dragItem = catsCopy[dragIdx]!; - catsCopy.splice(dragIdx, 1); - const targetIdx = catsCopy.findIndex(c => c.id === dropIndicator.targetId); - if (targetIdx !== -1) { - const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1; - catsCopy.splice(insertIdx, 0, dragItem); - } else { - catsCopy.push(dragItem); - } - catsCopy.forEach((c, i) => { c.position = i; }); - - const channelUpdates = channels.map(ch => ({ - id: ch.id, - position: ch.position, - categoryId: ch.categoryId, - })); - - // Optimistic update - useSpaceStore.getState().setCategories( - categories.map(cat => { - const update = catsCopy.find(u => u.id === cat.id); - if (update) return { ...cat, position: update.position }; - return cat; - }).sort((a, b) => a.position - b.position) - ); - - useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: catsCopy }); - } - } - - setChannelDragState(null); - setDropIndicator(null); - }, [channelDragState, dropIndicator, channels, sortedCategories, categories, currentSpaceId]); - const handleChannelClick = (channelId: string) => { setCurrentChannel(channelId); navigate(`/channels/${currentSpaceId || '@me'}/${channelId}`);