Files
backspace/packages/web/src/components/voice/VoiceChannel.tsx
T

223 lines
10 KiB
TypeScript

import React, { useCallback, useMemo } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
import { VoiceUserRow } from './VoiceUserRow';
const EMPTY_VOICE_USERS: string[] = [];
interface VoiceChannelProps {
channelId: string;
channelName: string;
onClick: () => void;
locked?: boolean;
canManage?: boolean;
onSettingsClick?: () => void;
voiceUserHandlers?: (userId: string, channelId: string) => {
draggable: boolean;
isBeingDragged: boolean;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: (e: React.DragEvent) => void;
};
dropZone?: {
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
isDragOver: boolean;
isValidTarget: boolean;
};
}
/** Wrapper component for the volume slider so it can use hooks (useState). */
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, voiceUserHandlers, dropZone }: VoiceChannelProps) {
const serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
// For OUR channel: LiveKit participants are the single source of truth.
// For other channels: use server-provided voiceUsers (only available source).
const voiceUsers = useMemo(() => {
if (currentVoiceChannel === channelId && isLiveKitConnected && participants.length > 0) {
return [...new Set(participants.map(p => p.userId))];
}
return serverVoiceUsers;
}, [currentVoiceChannel, channelId, isLiveKitConnected, participants, serverVoiceUsers]);
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const participantMutes = useVoiceStore((s) => s.participantMutes);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
const speakingUserIds = useVoiceStore((s) => s.speakingUserIds);
const currentUserId = useVoiceStore((s) => {
const local = s.participants.find(p => p.isLocal);
return local?.userId ?? null;
});
const members = useSpaceStore((s) => s.members);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const myUser = useAuthStore((s) => s.user);
const isActive = currentVoiceChannel === channelId;
const openContextMenu = useContextMenuStore((s) => s.open);
const handleContextMenu = useCallback(
(e: React.MouseEvent, userId: string) => {
if (userId === myUser?.id) return;
e.preventDefault();
e.stopPropagation();
// 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
items.push({
key: 'mute-user',
type: 'checkbox',
label: 'Mute User',
subscribe: useVoiceStore.subscribe,
getChecked: () => useVoiceStore.getState().participantMutes.get(userId) ?? false,
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, channelId, openContextMenu],
);
return (
<div
onDragOver={dropZone?.onDragOver}
onDragEnter={dropZone?.onDragEnter}
onDragLeave={dropZone?.onDragLeave}
onDrop={dropZone?.onDrop}
className={dropZone?.isDragOver && dropZone?.isValidTarget ? 'rounded-[8px] ring-1 ring-accent-mint/40' : ''}
>
<button
onClick={onClick}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
locked
? 'text-txt-tertiary/50 cursor-not-allowed'
: isActive
? 'bg-surface-elevated text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
title={locked ? "You don't have permission to connect to this channel" : undefined}
>
{isActive && !locked && (
<div
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
style={{ height: '55%', opacity: 0.7 }}
/>
)}
{locked ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]/50">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
</svg>
)}
<span className="truncate text-[15px] font-medium flex-1 text-left">{channelName}</span>
{canManage && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity"
onClick={(e) => {
e.stopPropagation();
onSettingsClick?.();
}}
>
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
)}
</button>
{/* Connected users */}
{voiceUsers.length > 0 && (
<div className="ml-9 mt-0.5 space-y-0.5">
{voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
const participant = participants.find(p => p.userId === userId);
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
const avatar = member?.user.avatar ?? null;
const avatarColor = member?.user.avatarColor;
const wsStatus = voiceUserStates.get(userId);
const isParticipantDeafened = userId === currentUserId
? localIsDeafened
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
const isMuted = userId === currentUserId
? localIsMuted
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false;
const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false;
const spaceId = channelToSpaceMap.get(channelId);
const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`);
const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`);
const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`);
const userDrag = voiceUserHandlers?.(userId, channelId);
const isDraggable = !!(userDrag?.draggable && userId !== myUser?.id);
const isBeingDragged = userDrag?.isBeingDragged ?? false;
return (
<div
key={userId}
className={`px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors ${
isDraggable ? 'cursor-grab active:cursor-grabbing' : ''
} ${isBeingDragged ? 'opacity-50' : ''}`}
draggable={isDraggable}
onDragStart={isDraggable ? userDrag!.onDragStart : undefined}
onDragEnd={isDraggable ? userDrag!.onDragEnd : undefined}
onContextMenu={(e) => handleContextMenu(e, userId)}
>
<VoiceUserRow
userId={member?.user.homeUserId ?? userId}
displayName={displayName}
avatar={avatar}
avatarColor={avatarColor ?? undefined}
isMuted={isMuted}
isDeafened={isParticipantDeafened}
isCameraOn={hasCamera}
isUnwatchedCamera={userId !== myUser?.id && unwatchedCameras.has(userId)}
isScreenSharing={isScreenSharing}
isServerMuted={isSpaceMuted}
isServerDeafened={isSpaceDeafened}
isPermissionMuted={isPermissionMuted}
isLocallyMuted={userId !== myUser?.id && (participantMutes.get(userId) ?? false)}
isSpeaking={speakingUserIds.has(userId)}
/>
</div>
);
})}
</div>
)}
</div>
);
}