fix: deduplicate VolumeSliderItem and add missing stopPropagation
- Extract VolumeSliderItem to voiceMenuItems.tsx (was duplicated in VoiceUser.tsx and VoiceChannel.tsx) - Add e.stopPropagation() to all imperative context menu handlers for consistency with useContextMenu hook behavior
This commit is contained in:
@@ -208,6 +208,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
const handleContextMenu = useCallback(
|
const handleContextMenu = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
const items: ContextMenuItem[] = [];
|
const items: ContextMenuItem[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||||
import { buildVoiceModMenuItems } from './voiceMenuItems';
|
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
|
|
||||||
@@ -28,35 +28,6 @@ interface VoiceChannelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Wrapper component for the volume slider so it can use hooks (useState). */
|
/** 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) {
|
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 serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
||||||
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
@@ -105,6 +76,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
|
|||||||
(e: React.MouseEvent, userId: string) => {
|
(e: React.MouseEvent, userId: string) => {
|
||||||
if (userId === myUser?.id) return;
|
if (userId === myUser?.id) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// Build moderation items
|
// Build moderation items
|
||||||
const modItems = buildVoiceModMenuItems(userId, channelId);
|
const modItems = buildVoiceModMenuItems(userId, channelId);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||||
import { buildVoiceModMenuItems } from './voiceMenuItems';
|
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
||||||
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
|
||||||
@@ -13,36 +13,6 @@ interface VoiceUserProps {
|
|||||||
large?: boolean;
|
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 (
|
|
||||||
<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 VoiceUser({ tile, large }: VoiceUserProps) {
|
export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
@@ -97,6 +67,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (isLocal || !currentVoiceChannelId) return;
|
if (isLocal || !currentVoiceChannelId) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
const targetUserId = participant.userId;
|
const targetUserId = participant.userId;
|
||||||
const channelId = currentVoiceChannelId;
|
const channelId = currentVoiceChannelId;
|
||||||
|
|||||||
+35
@@ -97,3 +97,38 @@ export function buildVoiceModMenuItems(targetUserId: string, channelId: string):
|
|||||||
|
|
||||||
return items;
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user