From 68a4c453dee824040121e4b445e6ffc745acad49 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:44:20 +0100 Subject: [PATCH] feat: drag-and-drop voice channel moves with federation fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add drag-and-drop support for moving users between voice channels (MOVE_MEMBERS permission required). Fix voice_moved handler using wrong user ID for federated users — now uses the same isHome/ getMyUserIdForOrigin pattern as adjacent voice handlers. --- .../src/components/layout/ChannelSidebar.tsx | 6 ++ .../web/src/components/voice/VoiceChannel.tsx | 69 ++++++++++++++++++- packages/web/src/hooks/useWebSocket.ts | 2 +- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 0732e1f0..81130d6d 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -39,6 +39,9 @@ export function ChannelSidebar() { const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); 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 isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`)); @@ -470,6 +473,9 @@ export function ChannelSidebar() { channelName={channel.name} onClick={() => canConnect && handleVoiceJoin(channel.id)} locked={!canConnect} + dragState={voiceDragState} + onDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })} + onDragEnd={() => setVoiceDragState(null)} /> ); })} diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index fe5a9af3..b1791a0c 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -4,17 +4,27 @@ import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { useAuthStore } from '../../stores/authStore'; import { Avatar } from '../ui/Avatar'; import { VoiceUserContextMenu } from './VoiceUserContextMenu'; +import { wsSend } from '../../hooks/useWebSocket'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; const EMPTY_VOICE_USERS: string[] = []; +interface VoiceChannelDragState { + userId: string; + fromChannelId: string; +} + interface VoiceChannelProps { channelId: string; channelName: string; onClick: () => void; locked?: boolean; + dragState?: VoiceChannelDragState | null; + onDragStart?: (userId: string) => void; + onDragEnd?: () => void; } -export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceChannelProps) { +export function VoiceChannel({ channelId, channelName, onClick, locked, dragState, onDragStart, onDragEnd }: VoiceChannelProps) { const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS; const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const participants = useVoiceStore((s) => s.participants); @@ -35,6 +45,16 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC const myUser = useAuthStore((s) => s.user); const isActive = currentVoiceChannel === channelId; + // Drag-and-drop permission check + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; + const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); + + // Drop target highlight state + const [isDragOver, setIsDragOver] = useState(false); + const isValidDropTarget = dragState !== null && dragState !== undefined && dragState.fromChannelId !== channelId; + // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; userId: string } | null>(null); @@ -48,7 +68,37 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC ); return ( -
+
{ + if (isValidDropTarget) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + } + }} + onDragEnter={(e) => { + if (isValidDropTarget) { + e.preventDefault(); + setIsDragOver(true); + } + }} + onDragLeave={(e) => { + // Only clear when leaving the container (not entering a child) + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragOver(false); + if (dragState && dragState.fromChannelId !== channelId) { + const voiceOrigin = getChannelOrigin(dragState.fromChannelId); + wsSend({ type: 'voice_move', userId: dragState.userId, targetChannelId: channelId }, voiceOrigin); + onDragEnd?.(); + } + }} + className={isDragOver && isValidDropTarget ? 'rounded-[8px] ring-1 ring-accent-mint/40' : ''} + >