feat(voice): журнал обработанных вебхуков LiveKit
This commit is contained in:
@@ -47,8 +47,7 @@ export interface VoiceModerationPatch {
|
||||
|
||||
/** Разобранное событие `VOICE_STATE_UPDATE`: состояние или уход из комнаты. */
|
||||
export type VoiceStateEvent =
|
||||
| { kind: 'state'; state: VoiceState }
|
||||
| { kind: 'left'; guildId: string; userId: string };
|
||||
{ kind: 'state'; state: VoiceState } | { kind: 'left'; guildId: string; userId: string };
|
||||
|
||||
export const guildVoiceStatesQueryKey = (guildId: string) =>
|
||||
['guilds', guildId, 'voice-states'] as const;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -187,7 +187,8 @@ export function canManageEmojis(
|
||||
}
|
||||
|
||||
/** Может ли пользователь отправлять сообщения в комнату. */
|
||||
export function canSendMessages( permissions: readonly string[],
|
||||
export function canSendMessages(
|
||||
permissions: readonly string[],
|
||||
isInstanceAdmin = false,
|
||||
channelCanSend?: boolean,
|
||||
): boolean {
|
||||
|
||||
@@ -81,10 +81,7 @@ function qualityOf(participant: Participant): VoiceQuality {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOf(
|
||||
track: Track | undefined,
|
||||
kind: 'audio' | 'video',
|
||||
): VoiceTrackHandle | undefined {
|
||||
function handleOf(track: Track | undefined, kind: 'audio' | 'video'): VoiceTrackHandle | undefined {
|
||||
if (track === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -111,7 +108,10 @@ function snapshotOf(participant: Participant, isLocal: boolean): VoiceParticipan
|
||||
const screen = participant.getTrackPublication(Track.Source.ScreenShare)?.track;
|
||||
const snapshot: VoiceParticipant = {
|
||||
userId: participant.identity,
|
||||
name: participant.name === undefined || participant.name === '' ? participant.identity : participant.name,
|
||||
name:
|
||||
participant.name === undefined || participant.name === ''
|
||||
? participant.identity
|
||||
: participant.name,
|
||||
isLocal,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
micEnabled: isLive(participant, Track.Source.Microphone),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChatView } from '@/components/chat/ChatView';
|
||||
import { PinsPanel } from '@/components/chat/PinsPanel';
|
||||
import { SearchPanel } from '@/components/chat/SearchPanel';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
||||
import { canManageMessages, canSendMessages } from '@/lib/identity';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
|
||||
@@ -76,9 +76,12 @@ export interface VoiceConnectionState {
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
/** Личная громкость участника, 0–100: хранится в предпочтениях клиента. */
|
||||
setVolume: (userId: string, volume: number) => void;
|
||||
setParticipantMuted: (userId: string, muted: boolean) => Promise<void>;
|
||||
setParticipantDeafened: (userId: string, deafened: boolean) => Promise<void>;
|
||||
moveParticipant: (userId: string, channelId: string) => Promise<void>;
|
||||
/** Серверный мьют участника: право `MUTE_MEMBERS` (модерация без входа в комнату). */
|
||||
setParticipantMuted: (guildId: string, userId: string, muted: boolean) => Promise<void>;
|
||||
/** Серверное глушение участника: право `DEAFEN_MEMBERS`. */
|
||||
setParticipantDeafened: (guildId: string, userId: string, deafened: boolean) => Promise<void>;
|
||||
/** Перемещение участника: право `MOVE_MEMBERS`. */
|
||||
moveParticipant: (guildId: string, userId: string, channelId: string) => Promise<void>;
|
||||
/** Переносит серверные флаги (server_mute/server_deaf) в локальную сессию. */
|
||||
syncServerState: () => void;
|
||||
dismissSpeakingWarning: () => void;
|
||||
@@ -485,11 +488,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
},
|
||||
|
||||
setParticipantMuted: async (userId, muted) => {
|
||||
const guildId = get().guildId;
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
setParticipantMuted: async (guildId, userId, muted) => {
|
||||
try {
|
||||
const states = await moderateVoiceState(guildId, userId, { server_mute: muted });
|
||||
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
||||
@@ -499,11 +498,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
},
|
||||
|
||||
setParticipantDeafened: async (userId, deafened) => {
|
||||
const guildId = get().guildId;
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
setParticipantDeafened: async (guildId, userId, deafened) => {
|
||||
try {
|
||||
const states = await moderateVoiceState(guildId, userId, { server_deaf: deafened });
|
||||
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
||||
@@ -513,11 +508,7 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
||||
}
|
||||
},
|
||||
|
||||
moveParticipant: async (userId, channelId) => {
|
||||
const guildId = get().guildId;
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
moveParticipant: async (guildId, userId, channelId) => {
|
||||
try {
|
||||
const states = await moveVoiceMember(guildId, userId, channelId);
|
||||
useSessionStore.getState().setGuildVoiceStates(guildId, states);
|
||||
|
||||
Reference in New Issue
Block a user