refactor: migrate voice context menus to centralized store

This commit is contained in:
Jannis Braun
2026-03-20 18:47:45 +01:00
parent cba19e8b20
commit 8eb3fc84a5
4 changed files with 506 additions and 51 deletions
@@ -3,7 +3,8 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { VoiceUserContextMenu } from './VoiceUserContextMenu';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { buildVoiceModMenuItems } from './voiceMenuItems';
import { wsSend } from '../../hooks/useWebSocket';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
@@ -26,6 +27,36 @@ interface VoiceChannelProps {
onDragEnd?: () => void;
}
/** Wrapper component for the volume slider so it can use hooks (useState). */
function VolumeSliderItem({ userId }: { userId: string }) {
const volume = useVoiceStore((s) => s.participantVolumes.get(userId) ?? 100);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
return (
<div className="p-3">
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
User Volume
</div>
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
<path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg>
<input
type="range"
min="0"
max="200"
value={volume}
onChange={(e) => setParticipantVolume(userId, parseInt(e.target.value))}
className="flex-1 accent-accent-primary h-1"
/>
<span className="text-xs text-txt-secondary min-w-[32px] text-right">
{volume}%
</span>
</div>
</div>
);
}
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, dragState, onDragStart, onDragEnd }: VoiceChannelProps) {
const serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
@@ -68,16 +99,45 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
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);
const openContextMenu = useContextMenuStore((s) => s.open);
const handleContextMenu = useCallback(
(e: React.MouseEvent, userId: string) => {
if (userId === myUser?.id) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, userId });
// Build moderation items
const modItems = buildVoiceModMenuItems(userId, channelId);
const items: ContextMenuItem[] = [...modItems];
// Separator after mod items
if (modItems.length > 0) {
items.push({ key: 'mod-end-sep', type: 'separator' });
}
// Mute User checkbox
const isUserMuted = useVoiceStore.getState().participantMutes.get(userId) ?? false;
items.push({
key: 'mute-user',
type: 'checkbox',
label: 'Mute User',
checked: isUserMuted,
onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked),
});
items.push({ key: 'vol-sep', type: 'separator' });
// Volume slider
items.push({
key: 'volume',
type: 'custom',
render: () => React.createElement(VolumeSliderItem, { userId }),
});
openContextMenu({ x: e.clientX, y: e.clientY }, items);
},
[myUser?.id],
[myUser?.id, channelId, openContextMenu],
);
return (
@@ -266,17 +326,6 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
})}
</div>
)}
{/* Voice moderation context menu (portalled to body) */}
{contextMenu && (
<VoiceUserContextMenu
targetUserId={contextMenu.userId}
channelId={channelId}
position={{ x: contextMenu.x, y: contextMenu.y }}
onClose={() => setContextMenu(null)}
isLocal={false}
/>
)}
</div>
);
}