Files
backspace/packages/web/src/components/voice/VoiceChannel.tsx
T
Jannis Braun 6a12fe2024 fix: rearchitect server mute/deafen pipeline to scope restrictions by spaceId
- Replaces global `userId` tracking with `spaceId:userId` composite keys across both backend and frontend, fixing the issue where server-muting a user in one space bled into others.
- Modifies client-side `ready` event hydration to merge voice states per-origin instead of completely overwriting the store, preventing federated connections from wiping out home instance mutes.
- Excludes server voice restrictions from `zustand/persist` so stale client caches don't override the server's authority on reload.
- Fixes React component reactivity by using reactive store selections for `spaceId` instead of imperative `getState()` calls, ensuring UI lockdown indicators accurately reflect the initial websocket handshake.
2026-03-09 19:44:37 +01:00

173 lines
8.9 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { VoiceModContextMenu } from './VoiceModContextMenu';
const EMPTY_VOICE_USERS: string[] = [];
interface VoiceChannelProps {
channelId: string;
channelName: string;
onClick: () => void;
locked?: boolean;
}
export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceChannelProps) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
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;
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; userId: string } | null>(null);
const handleContextMenu = useCallback(
(e: React.MouseEvent, userId: string) => {
if (userId === myUser?.id) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, userId });
},
[myUser?.id],
);
return (
<div>
<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">{channelName}</span>
</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 status = member?.user.status;
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 isServerMuted = serverMutedUserIds.has(`${spaceId}:${userId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`);
return (
<div
key={userId}
className="flex items-center gap-2 px-[10px] py-1 rounded-[6px] hover:bg-interactive-hover transition-colors"
onContextMenu={(e) => handleContextMenu(e, userId)}
>
<Avatar
src={avatar}
name={displayName}
size={24}
status={status}
userId={member?.user.homeUserId ?? userId}
/>
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0">
{(isServerMuted || isServerDeafened) && (
<span title={isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</span>
)}
{isServerDeafened && (
<span title="Server Deafened">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</span>
)}
{!isServerMuted && !isServerDeafened && isMuted && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
{!isServerDeafened && isParticipantDeafened && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
{hasCamera && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
</svg>
)}
{isScreenSharing && (
<span className="bg-accent-rose text-white text-[9px] font-bold px-1 rounded leading-[14px]">LIVE</span>
)}
</div>
</div>
);
})}
</div>
)}
{/* Voice moderation context menu (portalled to body) */}
{contextMenu && (
<VoiceModContextMenu
targetUserId={contextMenu.userId}
channelId={channelId}
position={{ x: contextMenu.x, y: contextMenu.y }}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}