diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx
index 9ff2af46..5dec42fb 100644
--- a/packages/web/src/components/voice/StreamTile.tsx
+++ b/packages/web/src/components/voice/StreamTile.tsx
@@ -208,6 +208,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
+ e.stopPropagation();
const items: ContextMenuItem[] = [];
diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx
index 9a68812a..111e11e4 100644
--- a/packages/web/src/components/voice/VoiceChannel.tsx
+++ b/packages/web/src/components/voice/VoiceChannel.tsx
@@ -4,7 +4,7 @@ import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
-import { buildVoiceModMenuItems } from './voiceMenuItems';
+import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
import { wsSend } from '../../hooks/useWebSocket';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
@@ -28,35 +28,6 @@ interface VoiceChannelProps {
}
/** 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 (
-
-
- User Volume
-
-
-
-
setParticipantVolume(userId, parseInt(e.target.value))}
- className="flex-1 accent-accent-primary h-1"
- />
-
- {volume}%
-
-
-
- );
-}
-
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);
@@ -105,6 +76,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
(e: React.MouseEvent, userId: string) => {
if (userId === myUser?.id) return;
e.preventDefault();
+ e.stopPropagation();
// Build moderation items
const modItems = buildVoiceModMenuItems(userId, channelId);
diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx
index e0572cbb..fc01ba53 100644
--- a/packages/web/src/components/voice/VoiceUser.tsx
+++ b/packages/web/src/components/voice/VoiceUser.tsx
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
-import { buildVoiceModMenuItems } from './voiceMenuItems';
+import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
import { useSpaceStore } from '../../stores/spaceStore';
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
@@ -13,36 +13,6 @@ interface VoiceUserProps {
large?: boolean;
}
-/** 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 (
-
-
- User Volume
-
-
-
-
setParticipantVolume(userId, parseInt(e.target.value))}
- className="flex-1 accent-accent-primary h-1"
- />
-
- {volume}%
-
-
-
- );
-}
-
export function VoiceUser({ tile, large }: VoiceUserProps) {
const videoRef = useRef(null);
@@ -97,6 +67,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
(e: React.MouseEvent) => {
if (isLocal || !currentVoiceChannelId) return;
e.preventDefault();
+ e.stopPropagation();
const targetUserId = participant.userId;
const channelId = currentVoiceChannelId;
diff --git a/packages/web/src/components/voice/voiceMenuItems.ts b/packages/web/src/components/voice/voiceMenuItems.tsx
similarity index 80%
rename from packages/web/src/components/voice/voiceMenuItems.ts
rename to packages/web/src/components/voice/voiceMenuItems.tsx
index 21dfe2c9..633f185e 100644
--- a/packages/web/src/components/voice/voiceMenuItems.ts
+++ b/packages/web/src/components/voice/voiceMenuItems.tsx
@@ -97,3 +97,38 @@ export function buildVoiceModMenuItems(targetUserId: string, channelId: string):
return items;
}
+
+// ── Shared custom menu item components ────────────────────────────────────
+
+/**
+ * Volume slider for a voice participant, used as a `custom` context menu item.
+ * Must be a component (not a plain function) because it subscribes to store state.
+ */
+export function VolumeSliderItem({ userId }: { userId: string }) {
+ const volume = useVoiceStore((s) => s.participantVolumes.get(userId) ?? 100);
+ const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
+
+ return (
+
+
+ User Volume
+
+
+
+
setParticipantVolume(userId, parseInt(e.target.value))}
+ className="flex-1 accent-accent-primary h-1"
+ />
+
+ {volume}%
+
+
+
+ );
+}