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
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.
234 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|