2026-09-20 18:38:54 +03:00
|
|
|
import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
|
|
|
|
import type { Channel } from '@/api/types';
|
|
|
|
|
import { Avatar } from '@/components/ui/Avatar';
|
|
|
|
|
import { Card, Button } from '@/components/ui/primitives';
|
|
|
|
|
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
2026-09-20 18:45:14 +03:00
|
|
|
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
|
2026-09-20 18:38:54 +03:00
|
|
|
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
|
|
|
|
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
|
|
|
|
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
|
|
|
|
import {
|
|
|
|
|
useVoiceChannels,
|
|
|
|
|
useVoiceModeration,
|
|
|
|
|
useVoiceOccupants,
|
|
|
|
|
type VoiceOccupant,
|
|
|
|
|
} from '@/components/voice/useVoiceOccupants';
|
|
|
|
|
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
2026-09-20 18:45:14 +03:00
|
|
|
import { speakingRingClass } from '@/components/voice/voiceStyles';
|
2026-09-20 18:38:54 +03:00
|
|
|
import type { VoiceModerationPermissions } from '@/lib/identity';
|
|
|
|
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
|
|
|
|
import { useVoiceStore } from '@/stores/voice';
|
|
|
|
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
|
|
|
|
|
|
|
|
|
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
|
|
|
|
function VoiceTile({
|
|
|
|
|
occupant,
|
|
|
|
|
permissions,
|
|
|
|
|
moveTargets,
|
|
|
|
|
onMute,
|
|
|
|
|
onDeafen,
|
|
|
|
|
onMove,
|
|
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
occupant: VoiceOccupant;
|
|
|
|
|
permissions: VoiceModerationPermissions;
|
|
|
|
|
moveTargets: Channel[];
|
|
|
|
|
onMute: (userId: string, muted: boolean) => void;
|
|
|
|
|
onDeafen: (userId: string, deafened: boolean) => void;
|
|
|
|
|
onMove: (userId: string, targetId: string) => void;
|
|
|
|
|
/** Кнопки управления — только на своей плитке. */
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
|
|
|
|
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
|
|
|
|
const media = occupant.participant;
|
|
|
|
|
const video = media?.screen ?? media?.video;
|
|
|
|
|
|
|
|
|
|
const onDragStart = (event: DragEvent<HTMLDivElement>): void => {
|
|
|
|
|
if (occupant.isLocal || !permissions.move) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
|
|
|
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
data-testid={`voice-tile-${occupant.userId}`}
|
|
|
|
|
data-speaking={occupant.isSpeaking}
|
|
|
|
|
draggable={permissions.move && !occupant.isLocal}
|
|
|
|
|
onDragStart={onDragStart}
|
|
|
|
|
className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 transition-colors duration-[var(--duration-fast)] ${
|
|
|
|
|
occupant.isSpeaking ? 'border-success/70' : 'border-border/60'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-[var(--radius-md)] bg-surface-2">
|
|
|
|
|
{video === undefined ? (
|
|
|
|
|
<span className={`rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
|
|
|
|
|
<Avatar
|
|
|
|
|
name={occupant.name}
|
|
|
|
|
seed={occupant.userId}
|
|
|
|
|
fileId={occupant.avatarFileId}
|
|
|
|
|
size="lg"
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<RemoteVideo track={video} muted={occupant.isLocal} />
|
|
|
|
|
)}
|
|
|
|
|
{media?.audio === undefined ? null : (
|
|
|
|
|
<RemoteAudio track={media.audio} volume={volume} muted={deafened} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
|
|
|
|
{occupant.name}
|
|
|
|
|
</span>
|
|
|
|
|
<VoiceQualityDot quality={occupant.quality} />
|
|
|
|
|
{occupant.isLocal ? (
|
|
|
|
|
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
ref={anchorRef}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
aria-expanded={menuOpen}
|
|
|
|
|
aria-label={t('voice.menu.label', { name: occupant.name })}
|
|
|
|
|
title={t('voice.menu.label', { name: occupant.name })}
|
|
|
|
|
onClick={() => setMenuOpen((value) => !value)}
|
|
|
|
|
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
|
|
|
|
>
|
|
|
|
|
<span aria-hidden="true">⋯</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<VoiceFlags
|
|
|
|
|
selfMute={occupant.selfMute}
|
|
|
|
|
selfDeaf={occupant.selfDeaf}
|
|
|
|
|
serverMute={occupant.serverMute}
|
|
|
|
|
serverDeaf={occupant.serverDeaf}
|
|
|
|
|
camera={occupant.camera}
|
|
|
|
|
screen={occupant.screen}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{occupant.isSpeaking ? <p className="sr-only">{t('voice.speaking')}</p> : null}
|
|
|
|
|
{children}
|
|
|
|
|
|
|
|
|
|
<VoiceOccupantMenu
|
|
|
|
|
open={menuOpen}
|
|
|
|
|
occupant={occupant}
|
|
|
|
|
anchorRef={anchorRef}
|
|
|
|
|
onClose={() => setMenuOpen(false)}
|
|
|
|
|
permissions={permissions}
|
|
|
|
|
moveTargets={moveTargets}
|
|
|
|
|
onMute={onMute}
|
|
|
|
|
onDeafen={onDeafen}
|
|
|
|
|
onMove={onMove}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Кнопки управления своей сессией: микрофон, звук, камера, экран, выход. */
|
|
|
|
|
function VoiceControlsRow({
|
|
|
|
|
micEnabled,
|
|
|
|
|
deafened,
|
|
|
|
|
cameraEnabled,
|
|
|
|
|
screenEnabled,
|
|
|
|
|
serverMuted,
|
|
|
|
|
serverDeafened,
|
|
|
|
|
onMic,
|
|
|
|
|
onSound,
|
|
|
|
|
onCamera,
|
|
|
|
|
onScreen,
|
|
|
|
|
onLeave,
|
|
|
|
|
}: {
|
|
|
|
|
micEnabled: boolean;
|
|
|
|
|
deafened: boolean;
|
|
|
|
|
cameraEnabled: boolean;
|
|
|
|
|
screenEnabled: boolean;
|
|
|
|
|
serverMuted: boolean;
|
|
|
|
|
serverDeafened: boolean;
|
|
|
|
|
onMic: () => void;
|
|
|
|
|
onSound: () => void;
|
|
|
|
|
onCamera: () => void;
|
|
|
|
|
onScreen: () => void;
|
|
|
|
|
onLeave: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="flex flex-wrap items-center gap-1"
|
|
|
|
|
role="group"
|
|
|
|
|
aria-label={t('voice.controls')}
|
|
|
|
|
>
|
|
|
|
|
<VoiceControlButton
|
|
|
|
|
glyph="🎤"
|
|
|
|
|
label={t(micEnabled ? 'voice.mic.off' : 'voice.mic.on')}
|
|
|
|
|
active={micEnabled}
|
|
|
|
|
disabled={serverMuted}
|
|
|
|
|
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
|
|
|
|
|
onClick={onMic}
|
|
|
|
|
/>
|
|
|
|
|
<VoiceControlButton
|
|
|
|
|
glyph="🎧"
|
|
|
|
|
label={t(deafened ? 'voice.sound.on' : 'voice.sound.off')}
|
|
|
|
|
active={!deafened}
|
|
|
|
|
disabled={serverDeafened}
|
|
|
|
|
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
|
|
|
|
|
onClick={onSound}
|
|
|
|
|
/>
|
|
|
|
|
<VoiceControlButton
|
|
|
|
|
glyph="🎥"
|
|
|
|
|
label={t(cameraEnabled ? 'voice.camera.off' : 'voice.camera.on')}
|
|
|
|
|
active={cameraEnabled}
|
|
|
|
|
onClick={onCamera}
|
|
|
|
|
/>
|
|
|
|
|
<VoiceControlButton
|
|
|
|
|
glyph="🖥"
|
|
|
|
|
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
|
|
|
|
active={screenEnabled}
|
|
|
|
|
onClick={onScreen}
|
|
|
|
|
/>
|
2026-09-20 18:45:14 +03:00
|
|
|
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
|
2026-09-20 18:38:54 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Кнопка управления голосом: иконка с состоянием и подписью. */
|
|
|
|
|
function VoiceControlButton({
|
|
|
|
|
glyph,
|
|
|
|
|
label,
|
|
|
|
|
active,
|
|
|
|
|
disabled = false,
|
|
|
|
|
disabledReason,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
glyph: string;
|
|
|
|
|
label: string;
|
|
|
|
|
active: boolean;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
disabledReason?: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const title = disabled && disabledReason !== undefined ? disabledReason : label;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-pressed={active}
|
|
|
|
|
aria-label={title}
|
|
|
|
|
title={title}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${
|
2026-09-20 18:45:14 +03:00
|
|
|
active
|
|
|
|
|
? 'border-border/60 bg-surface-2 text-fg'
|
|
|
|
|
: 'border-danger/60 bg-danger/10 text-danger'
|
2026-09-20 18:38:54 +03:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span aria-hidden="true">{glyph}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Панель голосовой комнаты в области контента: крупные плитки участников,
|
|
|
|
|
* управление своей сессией и модерация при наличии прав (AGENT.md 7.14).
|
|
|
|
|
*/
|
|
|
|
|
export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel: Channel }) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const voiceEnabled = useVoiceEnabled();
|
|
|
|
|
const describeError = useApiErrorMessage();
|
|
|
|
|
useGuildVoiceStates(guildId);
|
|
|
|
|
|
|
|
|
|
const occupants = useVoiceOccupants(guildId, channel.id);
|
|
|
|
|
const permissions = useVoiceModeration(guildId);
|
|
|
|
|
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
|
|
|
|
const status = useVoiceConnectionStore((state) => state.status);
|
|
|
|
|
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
|
|
|
|
const error = useVoiceConnectionStore((state) => state.error);
|
|
|
|
|
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
|
|
|
|
|
const cameraEnabled = useVoiceConnectionStore((state) => state.cameraEnabled);
|
|
|
|
|
const screenEnabled = useVoiceConnectionStore((state) => state.screenEnabled);
|
|
|
|
|
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
|
|
|
|
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
|
|
|
|
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
|
|
|
|
const refreshing = useVoiceConnectionStore((state) => state.refreshing);
|
|
|
|
|
const join = useVoiceConnectionStore((state) => state.join);
|
|
|
|
|
const leave = useVoiceConnectionStore((state) => state.leave);
|
|
|
|
|
const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
|
|
|
|
|
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
|
|
|
|
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
|
|
|
|
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
|
|
|
|
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
|
|
|
|
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
|
|
|
|
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
|
|
|
|
|
|
|
|
|
const here = activeChannelId === channel.id;
|
|
|
|
|
const connecting = here && status === 'connecting';
|
|
|
|
|
const inRoom = here && (status === 'connected' || status === 'reconnecting');
|
|
|
|
|
const failed = here && status === 'error';
|
|
|
|
|
|
|
|
|
|
const onMute = useCallback(
|
2026-09-20 18:45:14 +03:00
|
|
|
(userId: string, muted: boolean) => void setParticipantMuted(guildId, userId, muted),
|
|
|
|
|
[guildId, setParticipantMuted],
|
2026-09-20 18:38:54 +03:00
|
|
|
);
|
|
|
|
|
const onDeafen = useCallback(
|
2026-09-20 18:45:14 +03:00
|
|
|
(userId: string, deafenedNext: boolean) =>
|
|
|
|
|
void setParticipantDeafened(guildId, userId, deafenedNext),
|
|
|
|
|
[guildId, setParticipantDeafened],
|
2026-09-20 18:38:54 +03:00
|
|
|
);
|
|
|
|
|
const onMove = useCallback(
|
2026-09-20 18:45:14 +03:00
|
|
|
(userId: string, targetId: string) => void moveParticipant(guildId, userId, targetId),
|
|
|
|
|
[guildId, moveParticipant],
|
2026-09-20 18:38:54 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!voiceEnabled) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-1 items-center justify-center p-6">
|
|
|
|
|
<Card className="max-w-md text-center" data-testid="voice-disabled">
|
|
|
|
|
<h3 className="text-lg font-semibold">{t('voice.title')}</h3>
|
|
|
|
|
<p className="mt-2 text-sm text-fg-muted">{t('voice.disabled')}</p>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
{inRoom ? (
|
|
|
|
|
<p role="status" className="text-xs text-fg-muted">
|
|
|
|
|
{t(refreshing ? 'voice.refreshing' : 'voice.connected')}
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
disabled={connecting}
|
|
|
|
|
onClick={() => void join(guildId, channel.id)}
|
|
|
|
|
data-testid="voice-join"
|
|
|
|
|
>
|
|
|
|
|
{t(connecting ? 'voice.joining' : 'voice.join')}
|
|
|
|
|
</Button>
|
|
|
|
|
{connecting ? (
|
|
|
|
|
<p role="status" className="text-xs text-fg-muted">
|
|
|
|
|
{t('voice.joining')}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{failed ? (
|
|
|
|
|
<p role="alert" className="text-sm text-danger">
|
|
|
|
|
{describeError(error)}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{occupants.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-fg-muted">{t('voice.empty')}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
|
|
|
|
{occupants.map((occupant) => (
|
|
|
|
|
<VoiceTile
|
|
|
|
|
key={occupant.userId}
|
|
|
|
|
occupant={occupant}
|
|
|
|
|
permissions={permissions}
|
|
|
|
|
moveTargets={moveTargets}
|
|
|
|
|
onMute={onMute}
|
|
|
|
|
onDeafen={onDeafen}
|
|
|
|
|
onMove={onMove}
|
|
|
|
|
>
|
|
|
|
|
{occupant.isLocal && inRoom ? (
|
|
|
|
|
<VoiceControlsRow
|
|
|
|
|
micEnabled={micEnabled}
|
|
|
|
|
deafened={deafened}
|
|
|
|
|
cameraEnabled={cameraEnabled}
|
|
|
|
|
screenEnabled={screenEnabled}
|
|
|
|
|
serverMuted={serverMuted}
|
|
|
|
|
serverDeafened={serverDeafened}
|
|
|
|
|
onMic={() => void setMicEnabled(!micEnabled)}
|
|
|
|
|
onSound={() => void setDeafened(!deafened)}
|
|
|
|
|
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
|
|
|
|
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
|
|
|
|
onLeave={() => void leave()}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</VoiceTile>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|