Files
backspace/packages/web/src/components/voice/VoiceChannel.tsx
T
devsyncwrld f5451e1b14
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
feat: soundboard, account menu, and call timer
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.

Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.

Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.

Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
2026-08-31 13:45:18 -03:00

234 lines
11 KiB
TypeScript

import React, { useCallback, useMemo } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { CallTimer } from './CallTimer';
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) {
// Present only while someone is in the channel; the server drops the room
// when it empties, which is what makes the next call start from zero.
const callStartedAt = useVoiceStore((s) => s.voiceRoomStarts.get(channelId));
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>
{callStartedAt !== undefined && (
<CallTimer
startedAt={callStartedAt}
className="flex-shrink-0 text-[11px] text-txt-tertiary font-medium"
/>
)}
{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}
user={member?.user}
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>
);
}