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}% + +
+
+ ); +}