feat(voice): журнал обработанных вебхуков LiveKit

This commit is contained in:
2026-09-20 18:45:14 +03:00
parent ecd7b3be10
commit 0db4c63023
19 changed files with 1578 additions and 59 deletions
@@ -59,7 +59,7 @@ export function VoiceChannelItem({ guildId, channel, selected }: VoiceChannelIte
event.preventDefault();
const userId = draggedUserId(event);
if (userId !== '') {
void moveParticipant(userId, channel.id);
void moveParticipant(guildId, userId, channel.id);
}
};
+3 -8
View File
@@ -94,17 +94,12 @@ export function VoiceFlags({
<VoiceFlagIcon
glyph="🎧"
off={deafened}
label={t(serverDeaf ? 'voice.serverDeafened' : deafened ? 'voice.deafened' : 'voice.soundOn')}
label={t(
serverDeaf ? 'voice.serverDeafened' : deafened ? 'voice.deafened' : 'voice.soundOn',
)}
/>
{camera ? <VoiceFlagIcon glyph="🎥" label={t('voice.cameraOn')} /> : null}
{screen ? <VoiceFlagIcon glyph="🖥" label={t('voice.screenOn')} /> : null}
</span>
);
}
/** Кольцо вокруг аватара, когда участник говорит. */
export function speakingRingClass(speaking: boolean): string {
return speaking
? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1'
: 'ring-1 ring-border/60';
}
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '@/components/ui/Avatar';
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
import {
useVoiceChannels,
@@ -10,6 +10,7 @@ import {
type VoiceOccupant,
} from '@/components/voice/useVoiceOccupants';
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
import { speakingRingClass } from '@/components/voice/voiceStyles';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/**
@@ -91,9 +92,9 @@ export function VoiceOccupantRow({
onClose={close}
permissions={permissions}
moveTargets={moveTargets}
onMute={(userId, muted) => void setParticipantMuted(userId, muted)}
onDeafen={(userId, deafened) => void setParticipantDeafened(userId, deafened)}
onMove={(userId, targetId) => void moveParticipant(userId, targetId)}
onMute={(userId, muted) => void setParticipantMuted(guildId, userId, muted)}
onDeafen={(userId, deafened) => void setParticipantDeafened(guildId, userId, deafened)}
onMove={(userId, targetId) => void moveParticipant(guildId, userId, targetId)}
/>
</li>
);
+13 -14
View File
@@ -5,7 +5,7 @@ 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';
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
@@ -16,6 +16,7 @@ import {
type VoiceOccupant,
} from '@/components/voice/useVoiceOccupants';
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
import { speakingRingClass } from '@/components/voice/voiceStyles';
import type { VoiceModerationPermissions } from '@/lib/identity';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useVoiceStore } from '@/stores/voice';
@@ -195,12 +196,7 @@ function VoiceControlsRow({
active={screenEnabled}
onClick={onScreen}
/>
<VoiceControlButton
glyph="⏻"
label={t('voice.disconnect')}
active
onClick={onLeave}
/>
<VoiceControlButton glyph="⏻" label={t('voice.disconnect')} active onClick={onLeave} />
</div>
);
}
@@ -231,7 +227,9 @@ function VoiceControlButton({
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 ${
active ? 'border-border/60 bg-surface-2 text-fg' : 'border-danger/60 bg-danger/10 text-danger'
active
? 'border-border/60 bg-surface-2 text-fg'
: 'border-danger/60 bg-danger/10 text-danger'
}`}
>
<span aria-hidden="true">{glyph}</span>
@@ -278,16 +276,17 @@ export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel:
const failed = here && status === 'error';
const onMute = useCallback(
(userId: string, muted: boolean) => void setParticipantMuted(userId, muted),
[setParticipantMuted],
(userId: string, muted: boolean) => void setParticipantMuted(guildId, userId, muted),
[guildId, setParticipantMuted],
);
const onDeafen = useCallback(
(userId: string, deafenedNext: boolean) => void setParticipantDeafened(userId, deafenedNext),
[setParticipantDeafened],
(userId: string, deafenedNext: boolean) =>
void setParticipantDeafened(guildId, userId, deafenedNext),
[guildId, setParticipantDeafened],
);
const onMove = useCallback(
(userId: string, targetId: string) => void moveParticipant(userId, targetId),
[moveParticipant],
(userId: string, targetId: string) => void moveParticipant(guildId, userId, targetId),
[guildId, moveParticipant],
);
if (!voiceEnabled) {
+2 -1
View File
@@ -16,5 +16,6 @@ export function useVoiceEnabled(): boolean {
staleTime: 300_000,
retry: 0,
});
return meta.data?.features.voice_enabled !== false;
// Ответ может быть неполным (старый сервер, тестовый мок) — тогда голос включён.
return meta.data?.features?.voice_enabled !== false;
}
@@ -38,7 +38,10 @@ const NO_OCCUPANTS: VoiceOccupant[] = [];
* Кто в комнате: список строится из `voice_states` (один источник правды),
* имена и аватары — из участников сервера, речь и качество — из SDK.
*/
export function useVoiceOccupants(guildId: string | null, channelId: string | null): VoiceOccupant[] {
export function useVoiceOccupants(
guildId: string | null,
channelId: string | null,
): VoiceOccupant[] {
const voiceStates = useSessionStore((state) => state.voiceStates);
const sessionUser = useSessionStore((state) => state.user);
const mediaParticipants = useVoiceConnectionStore((state) =>
+11
View File
@@ -0,0 +1,11 @@
/**
* Классы состояний голосового интерфейса. Вынесены из компонентов, чтобы
* файлы с разметкой экспортировали только компоненты (fast refresh).
*/
/** Кольцо вокруг аватара: зелёное, когда участник говорит. */
export function speakingRingClass(speaking: boolean): string {
return speaking
? 'ring-2 ring-success ring-offset-1 ring-offset-surface-1'
: 'ring-1 ring-border/60';
}