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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user