feat: drag-and-drop voice channel moves with federation fix

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.
This commit is contained in:
Jannis Braun
2026-03-11 23:44:20 +01:00
parent 825e9975c8
commit 68a4c453de
3 changed files with 73 additions and 4 deletions
@@ -39,6 +39,9 @@ export function ChannelSidebar() {
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); 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 isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`));
const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`));
const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`));
@@ -470,6 +473,9 @@ export function ChannelSidebar() {
channelName={channel.name} channelName={channel.name}
onClick={() => canConnect && handleVoiceJoin(channel.id)} onClick={() => canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect} locked={!canConnect}
dragState={voiceDragState}
onDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
onDragEnd={() => setVoiceDragState(null)}
/> />
); );
})} })}
@@ -4,17 +4,27 @@ import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { VoiceUserContextMenu } from './VoiceUserContextMenu'; import { VoiceUserContextMenu } from './VoiceUserContextMenu';
import { wsSend } from '../../hooks/useWebSocket';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
const EMPTY_VOICE_USERS: string[] = []; const EMPTY_VOICE_USERS: string[] = [];
interface VoiceChannelDragState {
userId: string;
fromChannelId: string;
}
interface VoiceChannelProps { interface VoiceChannelProps {
channelId: string; channelId: string;
channelName: string; channelName: string;
onClick: () => void; onClick: () => void;
locked?: boolean; 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 voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants); 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 myUser = useAuthStore((s) => s.user);
const isActive = currentVoiceChannel === channelId; 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 // Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; userId: string } | null>(null); 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 ( return (
<div> <div
onDragOver={(e) => {
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' : ''}
>
<button <button
onClick={onClick} onClick={onClick}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${ className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
@@ -101,10 +151,23 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`); const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`);
const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`);
const isDraggable = canMoveMembers && userId !== myUser?.id;
const isBeingDragged = dragState?.userId === userId && dragState?.fromChannelId === channelId;
return ( return (
<div <div
key={userId} key={userId}
className="flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors" className={`flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors ${
isDraggable ? 'cursor-grab active:cursor-grabbing' : ''
} ${isBeingDragged ? 'opacity-50' : ''}`}
draggable={isDraggable}
onDragStart={(e) => {
if (!isDraggable) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', userId);
onDragStart?.(userId);
}}
onDragEnd={() => onDragEnd?.()}
onContextMenu={(e) => handleContextMenu(e, userId)} onContextMenu={(e) => handleContextMenu(e, userId)}
> >
<Avatar <Avatar
+1 -1
View File
@@ -366,7 +366,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'voice_moved': { case 'voice_moved': {
// The local user was moved to a different channel by a moderator // The local user was moved to a different channel by a moderator
const myMovedId = useAuthStore.getState().user?.id; const myMovedId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
if (event.userId === myMovedId) { if (event.userId === myMovedId) {
// Import dynamically to avoid circular deps — joinVoiceChannel handles // Import dynamically to avoid circular deps — joinVoiceChannel handles
// leaving old channel, setting new channel, and triggering LiveKit reconnect // leaving old channel, setting new channel, and triggering LiveKit reconnect