fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { VoiceTrackHandle } from '@/lib/livekit';
|
||||
|
||||
/**
|
||||
* Аудио удалённого участника: дорожка подключается к своему элементу, а
|
||||
* громкость и глушение применяются к нему же (AGENT.md 7.14).
|
||||
*/
|
||||
export function RemoteAudio({
|
||||
track,
|
||||
volume,
|
||||
muted,
|
||||
}: {
|
||||
track: VoiceTrackHandle;
|
||||
/** Громкость 0–100 (личная настройка участника). */
|
||||
volume: number;
|
||||
/** Локальное глушение: звук выключен целиком. */
|
||||
muted: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element === null) {
|
||||
return;
|
||||
}
|
||||
track.attach(element);
|
||||
return () => {
|
||||
track.detach(element);
|
||||
};
|
||||
}, [track]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element === null) {
|
||||
return;
|
||||
}
|
||||
element.volume = Math.min(1, Math.max(0, volume / 100));
|
||||
element.muted = muted;
|
||||
}, [volume, muted]);
|
||||
|
||||
return <audio ref={ref} autoPlay playsInline data-testid="voice-remote-audio" />;
|
||||
}
|
||||
|
||||
/** Видео участника: камера или демонстрация экрана. */
|
||||
export function RemoteVideo({
|
||||
track,
|
||||
muted,
|
||||
className = '',
|
||||
}: {
|
||||
track: VoiceTrackHandle;
|
||||
/** Свой предпросмотр не должен звучать и зацикливаться. */
|
||||
muted: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element === null) {
|
||||
return;
|
||||
}
|
||||
track.attach(element);
|
||||
return () => {
|
||||
track.detach(element);
|
||||
};
|
||||
}, [track]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={muted}
|
||||
data-testid="voice-video"
|
||||
className={`h-full w-full object-cover ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useRef, useState, type DragEvent, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
||||
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
||||
import { Menu, MenuItem } from '@/components/ui/Menu';
|
||||
import { VoiceOccupantRow } from '@/components/voice/VoiceOccupantRow';
|
||||
import { allowVoiceDrop, draggedUserId } from '@/components/voice/voiceDrag';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { useVoiceModeration, useVoiceOccupants } from '@/components/voice/useVoiceOccupants';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
interface VoiceChannelItemProps {
|
||||
guildId: string;
|
||||
channel: Channel;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Голосовая комната в сайдбаре: клик — вход (повторный клик по своей комнате
|
||||
* ничего не делает), двойной клик или меню — «Отключиться». Под комнатой
|
||||
* видно, кто в ней сидит; при праве MOVE_MEMBERS участника можно перетащить
|
||||
* на другую голосовую комнату.
|
||||
*/
|
||||
export function VoiceChannelItem({ guildId, channel, selected }: VoiceChannelItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const voiceEnabled = useVoiceEnabled();
|
||||
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||
const error = useVoiceConnectionStore((state) => state.error);
|
||||
const refreshing = useVoiceConnectionStore((state) => state.refreshing);
|
||||
const join = useVoiceConnectionStore((state) => state.join);
|
||||
const leave = useVoiceConnectionStore((state) => state.leave);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
const describeError = useApiErrorMessage();
|
||||
const { unread, mentions } = useChannelUnread(channel.id);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
|
||||
/** Комната, в которой мы сейчас находимся (включая подключение и ошибку). */
|
||||
const here = activeChannelId === channel.id && status !== 'idle';
|
||||
|
||||
const highlighted = !selected && (unread > 0 || mentions > 0);
|
||||
const label =
|
||||
mentions > 0
|
||||
? t('channels.selectMentions', { name: channel.name, count: mentions })
|
||||
: unread > 0
|
||||
? t('channels.selectUnread', { name: channel.name, count: unread })
|
||||
: t('channels.select', { name: channel.name });
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLAnchorElement>): void => {
|
||||
event.preventDefault();
|
||||
const userId = draggedUserId(event);
|
||||
if (userId !== '') {
|
||||
void moveParticipant(userId, channel.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onDoubleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
if (!here) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void leave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<NavLink
|
||||
ref={anchorRef}
|
||||
to={`/app/${guildId}/${channel.id}`}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
aria-label={label}
|
||||
title={voiceEnabled ? channel.name : t('voice.disabled')}
|
||||
onClick={() => {
|
||||
if (voiceEnabled) {
|
||||
void join(guildId, channel.id);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
onDragOver={permissions.move ? allowVoiceDrop : undefined}
|
||||
onDrop={permissions.move ? onDrop : undefined}
|
||||
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
selected
|
||||
? 'gl-indicator bg-surface-3 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
|
||||
} ${highlighted ? 'font-semibold text-fg' : ''}`}
|
||||
>
|
||||
<span aria-hidden="true">🔊</span>
|
||||
<span className="truncate">{channel.name}</span>
|
||||
<ChannelBadge channelId={channel.id} selected={selected} />
|
||||
</NavLink>
|
||||
|
||||
{here && status === 'connecting' ? (
|
||||
<p role="status" className="px-2 pl-7 text-xs text-fg-muted">
|
||||
{t('voice.joining')}
|
||||
</p>
|
||||
) : null}
|
||||
{here && status === 'reconnecting' ? (
|
||||
<p role="status" className="px-2 pl-7 text-xs text-warning">
|
||||
{t('voice.reconnecting')}
|
||||
</p>
|
||||
) : null}
|
||||
{here && refreshing ? (
|
||||
<p role="status" className="px-2 pl-7 text-xs text-fg-muted">
|
||||
{t('voice.refreshing')}
|
||||
</p>
|
||||
) : null}
|
||||
{here && status === 'error' ? (
|
||||
<p role="alert" className="px-2 pl-7 text-xs text-danger">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{occupants.length === 0 ? null : (
|
||||
<ul
|
||||
aria-label={t('voice.channelParticipants', { name: channel.name })}
|
||||
className="mb-0.5 flex flex-col gap-0.5 pl-4"
|
||||
>
|
||||
{occupants.map((occupant) => (
|
||||
<VoiceOccupantRow
|
||||
key={occupant.userId}
|
||||
occupant={occupant}
|
||||
guildId={guildId}
|
||||
channelId={channel.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
label={t('voice.channelMenu.label', { name: channel.name })}
|
||||
anchorRef={anchorRef}
|
||||
onClose={closeMenu}
|
||||
className="w-56"
|
||||
>
|
||||
{here ? (
|
||||
<MenuItem
|
||||
icon="⏻"
|
||||
danger
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
void leave();
|
||||
}}
|
||||
>
|
||||
{t('voice.disconnect')}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem
|
||||
icon="🔊"
|
||||
disabled={!voiceEnabled}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
void join(guildId, channel.id);
|
||||
}}
|
||||
>
|
||||
{t(voiceEnabled ? 'voice.join' : 'voice.disabled')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { VoiceQuality } from '@/lib/livekit';
|
||||
|
||||
/** Цвет точки качества связи: зелёный — отлично, красный — связи нет. */
|
||||
const qualityClass: Record<VoiceQuality, string> = {
|
||||
excellent: 'bg-success',
|
||||
good: 'bg-success/70',
|
||||
poor: 'bg-warning',
|
||||
lost: 'bg-danger',
|
||||
unknown: 'bg-fg-muted',
|
||||
};
|
||||
|
||||
/** Точка качества связи с подписью для скринридера (AGENT.md 7.14). */
|
||||
export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (quality === null) {
|
||||
return null;
|
||||
}
|
||||
const label = t(`voice.quality.${quality}`);
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-testid="voice-quality"
|
||||
data-quality={quality}
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${qualityClass[quality]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Иконка состояния участника: перечёркнутая, когда флаг выключен.
|
||||
* Подпись обязательна — по ней состояние читают и тесты, и скринридер.
|
||||
*/
|
||||
export function VoiceFlagIcon({
|
||||
glyph,
|
||||
label,
|
||||
off = false,
|
||||
}: {
|
||||
glyph: string;
|
||||
label: string;
|
||||
off?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`relative inline-block leading-none ${off ? 'text-danger' : 'text-fg-muted'}`}
|
||||
>
|
||||
<span aria-hidden="true" className={off ? 'opacity-60' : ''}>
|
||||
{glyph}
|
||||
</span>
|
||||
{off ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 h-[1.5px] w-4 -translate-x-1/2 -translate-y-1/2 -rotate-45 rounded-full bg-current"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Флаги участника: микрофон, звук, камера, демонстрация экрана. */
|
||||
export function VoiceFlags({
|
||||
selfMute,
|
||||
selfDeaf,
|
||||
serverMute,
|
||||
serverDeaf,
|
||||
camera,
|
||||
screen,
|
||||
className = '',
|
||||
}: {
|
||||
selfMute: boolean;
|
||||
selfDeaf: boolean;
|
||||
serverMute: boolean;
|
||||
serverDeaf: boolean;
|
||||
camera: boolean;
|
||||
screen: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const muted = selfMute || serverMute;
|
||||
const deafened = selfDeaf || serverDeaf;
|
||||
return (
|
||||
<span className={`flex items-center gap-1.5 text-xs ${className}`}>
|
||||
<VoiceFlagIcon
|
||||
glyph="🎤"
|
||||
off={muted}
|
||||
label={t(serverMute ? 'voice.serverMuted' : muted ? 'voice.muted' : 'voice.micOn')}
|
||||
/>
|
||||
<VoiceFlagIcon
|
||||
glyph="🎧"
|
||||
off={deafened}
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||
import { VolumeSlider } from '@/components/voice/VoiceControls';
|
||||
import type { VoiceOccupant } from '@/components/voice/useVoiceOccupants';
|
||||
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
|
||||
export interface VoiceOccupantMenuProps {
|
||||
open: boolean;
|
||||
occupant: VoiceOccupant;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
permissions: VoiceModerationPermissions;
|
||||
/** Куда можно переместить участника (без его текущей комнаты). */
|
||||
moveTargets: Channel[];
|
||||
onMute: (userId: string, muted: boolean) => void;
|
||||
onDeafen: (userId: string, deafened: boolean) => void;
|
||||
onMove: (userId: string, channelId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекстное меню участника: личная громкость и — при наличии прав —
|
||||
* серверный мьют, глушение и перемещение в другую голосовую комнату.
|
||||
*/
|
||||
export function VoiceOccupantMenu({
|
||||
open,
|
||||
occupant,
|
||||
anchorRef,
|
||||
onClose,
|
||||
permissions,
|
||||
moveTargets,
|
||||
onMute,
|
||||
onDeafen,
|
||||
onMove,
|
||||
}: VoiceOccupantMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
||||
const setParticipantVolume = useVoiceStore((state) => state.setParticipantVolume);
|
||||
const canModerate =
|
||||
!occupant.isLocal && (permissions.mute || permissions.deafen || permissions.move);
|
||||
|
||||
const run = (action: () => void): void => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
label={t('voice.menu.label', { name: occupant.name })}
|
||||
anchorRef={anchorRef}
|
||||
onClose={onClose}
|
||||
className="w-64"
|
||||
>
|
||||
<MenuGroupLabel>{occupant.name}</MenuGroupLabel>
|
||||
{occupant.isLocal ? null : (
|
||||
<VolumeSlider
|
||||
label={t('voice.volume')}
|
||||
value={volume}
|
||||
onChange={(value) => setParticipantVolume(occupant.userId, value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canModerate ? <MenuSeparator /> : null}
|
||||
{canModerate ? <MenuGroupLabel>{t('voice.menu.moderation')}</MenuGroupLabel> : null}
|
||||
{permissions.mute && !occupant.isLocal ? (
|
||||
<MenuItem
|
||||
icon={occupant.serverMute ? '🔊' : '🔇'}
|
||||
onClick={() => run(() => onMute(occupant.userId, !occupant.serverMute))}
|
||||
>
|
||||
{t(occupant.serverMute ? 'voice.menu.unmute' : 'voice.menu.mute')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{permissions.deafen && !occupant.isLocal ? (
|
||||
<MenuItem
|
||||
icon={occupant.serverDeaf ? '🔔' : '🔕'}
|
||||
onClick={() => run(() => onDeafen(occupant.userId, !occupant.serverDeaf))}
|
||||
>
|
||||
{t(occupant.serverDeaf ? 'voice.menu.undeafen' : 'voice.menu.deafen')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{permissions.move && !occupant.isLocal && moveTargets.length > 0 ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuGroupLabel>{t('voice.menu.move')}</MenuGroupLabel>
|
||||
{moveTargets.map((target) => (
|
||||
<MenuItem
|
||||
key={target.id}
|
||||
icon="➡"
|
||||
onClick={() => run(() => onMove(occupant.userId, target.id))}
|
||||
>
|
||||
{t('voice.menu.moveTo', { name: target.name })}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import {
|
||||
useVoiceChannels,
|
||||
useVoiceModeration,
|
||||
type VoiceOccupant,
|
||||
} from '@/components/voice/useVoiceOccupants';
|
||||
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/**
|
||||
* Строка участника голосовой комнаты в сайдбаре: аватар с кольцом речи,
|
||||
* имя, флаги и точка качества связи. Клик открывает меню с громкостью и
|
||||
* действиями модератора; при праве MOVE_MEMBERS строку можно перетащить
|
||||
* на другую голосовую комнату.
|
||||
*/
|
||||
export function VoiceOccupantRow({
|
||||
occupant,
|
||||
guildId,
|
||||
channelId,
|
||||
}: {
|
||||
occupant: VoiceOccupant;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
|
||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const label = t('voice.menu.label', { name: occupant.name });
|
||||
const canDrag = permissions.move && !occupant.isLocal;
|
||||
|
||||
const onDragStart = (event: DragEvent<HTMLButtonElement>): void => {
|
||||
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="flex items-center">
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? onDragStart : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--radius-sm)] px-2 py-0.5 text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span className={`shrink-0 rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
|
||||
<Avatar
|
||||
name={occupant.name}
|
||||
seed={occupant.userId}
|
||||
fileId={occupant.avatarFileId}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{occupant.name}</span>
|
||||
{occupant.isSpeaking ? <span className="sr-only">{t('voice.speaking')}</span> : null}
|
||||
<VoiceFlags
|
||||
className="shrink-0"
|
||||
selfMute={occupant.selfMute}
|
||||
selfDeaf={occupant.selfDeaf}
|
||||
serverMute={occupant.serverMute}
|
||||
serverDeaf={occupant.serverDeaf}
|
||||
camera={occupant.camera}
|
||||
screen={occupant.screen}
|
||||
/>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
</button>
|
||||
|
||||
<VoiceOccupantMenu
|
||||
open={open}
|
||||
occupant={occupant}
|
||||
anchorRef={anchorRef}
|
||||
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)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
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';
|
||||
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
|
||||
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';
|
||||
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}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="⏻"
|
||||
label={t('voice.disconnect')}
|
||||
active
|
||||
onClick={onLeave}
|
||||
/>
|
||||
</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 ${
|
||||
active ? 'border-border/60 bg-surface-2 text-fg' : 'border-danger/60 bg-danger/10 text-danger'
|
||||
}`}
|
||||
>
|
||||
<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(
|
||||
(userId: string, muted: boolean) => void setParticipantMuted(userId, muted),
|
||||
[setParticipantMuted],
|
||||
);
|
||||
const onDeafen = useCallback(
|
||||
(userId: string, deafenedNext: boolean) => void setParticipantDeafened(userId, deafenedNext),
|
||||
[setParticipantDeafened],
|
||||
);
|
||||
const onMove = useCallback(
|
||||
(userId: string, targetId: string) => void moveParticipant(userId, targetId),
|
||||
[moveParticipant],
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/**
|
||||
* Подсказка «вы говорите с выключенным микрофоном» (AGENT.md 7.14).
|
||||
*
|
||||
* Появляется по сигналу SDK не чаще одного раза в 5 секунд и живёт несколько
|
||||
* секунд; показывает её оболочка приложения, чтобы подсказку было видно с
|
||||
* любого экрана, а не только из голосовой комнаты.
|
||||
*/
|
||||
export function VoiceSpeakingNotice() {
|
||||
const { t } = useTranslation();
|
||||
const visible = useVoiceConnectionStore((state) => state.speakingWhileMuted);
|
||||
const dismiss = useVoiceConnectionStore((state) => state.dismissSpeakingWarning);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="voice-speaking-notice"
|
||||
className="pointer-events-auto fixed bottom-24 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-[var(--radius-md)] border border-warning/60 bg-surface-2 px-3 py-2 text-sm shadow-[var(--shadow-3)]"
|
||||
>
|
||||
<span aria-hidden="true">🎤</span>
|
||||
<p>{t('voice.speakingWhileMuted')}</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.dismiss')}
|
||||
title={t('common.dismiss')}
|
||||
onClick={dismiss}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchVoiceStates, guildVoiceStatesQueryKey } from '@/api/voice';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Подстраховка к шлюзу: пока открыт сервер, один раз (и по устареванию)
|
||||
* перечитываем `GET /guilds/{id}/voice-states`.
|
||||
*
|
||||
* Основной источник — READY и `VOICE_STATE_UPDATE`; REST нужен, если событие
|
||||
* потерялось или вкладка уснула. Ошибку не показываем: у голосовых комнат
|
||||
* есть свои состояния подключения.
|
||||
*/
|
||||
export function useGuildVoiceStates(guildId: string | null): void {
|
||||
const setGuildVoiceStates = useSessionStore((state) => state.setGuildVoiceStates);
|
||||
const voice = useQuery({
|
||||
queryKey: guildVoiceStatesQueryKey(guildId ?? ''),
|
||||
queryFn: ({ signal }) => fetchVoiceStates(guildId ?? '', signal),
|
||||
enabled: guildId !== null,
|
||||
staleTime: 30_000,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (guildId === null || voice.data === undefined) {
|
||||
return;
|
||||
}
|
||||
setGuildVoiceStates(guildId, voice.data);
|
||||
}, [guildId, voice.data, setGuildVoiceStates]);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||
|
||||
/**
|
||||
* Доступен ли голос на инстансе (`GET /meta`, `features.voice_enabled`).
|
||||
*
|
||||
* Пока мета не загрузилась или недоступна, считаем голос включённым: иначе
|
||||
* сетевая ошибка молча отключала бы голосовые комнаты, а понятную ошибку
|
||||
* всё равно вернёт сервер при подключении.
|
||||
*/
|
||||
export function useVoiceEnabled(): boolean {
|
||||
const meta = useQuery({
|
||||
queryKey: metaQueryKey,
|
||||
queryFn: ({ signal }) => fetchMeta(signal),
|
||||
staleTime: 300_000,
|
||||
retry: 0,
|
||||
});
|
||||
return meta.data?.features.voice_enabled !== false;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import type { VoiceQuality, VoiceParticipant } from '@/lib/livekit';
|
||||
import { voiceModerationPermissions, type VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore, voiceStatesOfChannel } from '@/stores/session';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/**
|
||||
* Участник голосовой комнаты для интерфейса: серверное состояние
|
||||
* (`voice_states`) плюс медиа-данные SDK, если мы сами в этой комнате.
|
||||
*/
|
||||
export interface VoiceOccupant {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatarFileId: string | undefined;
|
||||
isLocal: boolean;
|
||||
isSpeaking: boolean;
|
||||
selfMute: boolean;
|
||||
selfDeaf: boolean;
|
||||
serverMute: boolean;
|
||||
serverDeaf: boolean;
|
||||
camera: boolean;
|
||||
screen: boolean;
|
||||
/** Качество связи знает только SDK: вне своей комнаты его нет. */
|
||||
quality: VoiceQuality | null;
|
||||
/** Дорожки и флаги LiveKit: только для участников своей комнаты. */
|
||||
participant: VoiceParticipant | null;
|
||||
}
|
||||
|
||||
/** Стабильная пустая ссылка: селектор не должен пересоздавать массив. */
|
||||
const NO_PARTICIPANTS: VoiceParticipant[] = [];
|
||||
const NO_OCCUPANTS: VoiceOccupant[] = [];
|
||||
|
||||
/**
|
||||
* Кто в комнате: список строится из `voice_states` (один источник правды),
|
||||
* имена и аватары — из участников сервера, речь и качество — из SDK.
|
||||
*/
|
||||
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) =>
|
||||
state.channelId === channelId && channelId !== null ? state.participants : NO_PARTICIPANTS,
|
||||
);
|
||||
const connectedHere = useVoiceConnectionStore(
|
||||
(state) => channelId !== null && state.channelId === channelId,
|
||||
);
|
||||
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
|
||||
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||
const cameraEnabled = useVoiceConnectionStore((state) => state.cameraEnabled);
|
||||
const screenEnabled = useVoiceConnectionStore((state) => state.screenEnabled);
|
||||
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
||||
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
||||
|
||||
const states = useMemo(
|
||||
() => (channelId === null ? [] : voiceStatesOfChannel(voiceStates, channelId)),
|
||||
[voiceStates, channelId],
|
||||
);
|
||||
const userIds = useMemo(() => states.map((state) => state.user_id), [states]);
|
||||
const directory = useAuthorDirectory(guildId, userIds);
|
||||
|
||||
return useMemo(() => {
|
||||
if (channelId === null) {
|
||||
return NO_OCCUPANTS;
|
||||
}
|
||||
const occupants = states.map((state): VoiceOccupant => {
|
||||
const media = mediaParticipants.find((item) => item.userId === state.user_id) ?? null;
|
||||
const author = directory.resolve(state.user_id);
|
||||
return {
|
||||
userId: state.user_id,
|
||||
name: author.name,
|
||||
avatarFileId: author.avatarFileId,
|
||||
isLocal: state.user_id === sessionUser?.id,
|
||||
isSpeaking: media?.isSpeaking === true,
|
||||
selfMute: state.self_mute,
|
||||
selfDeaf: state.self_deaf,
|
||||
serverMute: state.server_mute,
|
||||
serverDeaf: state.server_deaf,
|
||||
camera: state.camera || media?.cameraEnabled === true,
|
||||
screen: state.screen || media?.screenEnabled === true,
|
||||
quality: media?.quality ?? null,
|
||||
participant: media,
|
||||
};
|
||||
});
|
||||
|
||||
// Своё состояние приходит и событием, но при подключении может опоздать:
|
||||
// рисуем себя сразу, чтобы не пропадать из собственной комнаты.
|
||||
const me = sessionUser;
|
||||
if (connectedHere && me !== null && !occupants.some((item) => item.isLocal)) {
|
||||
const media = mediaParticipants.find((item) => item.userId === me.id) ?? null;
|
||||
occupants.unshift({
|
||||
userId: me.id,
|
||||
name: me.display_name === '' ? me.username : me.display_name,
|
||||
avatarFileId: me.avatar_file_id,
|
||||
isLocal: true,
|
||||
isSpeaking: media?.isSpeaking === true,
|
||||
selfMute: !micEnabled,
|
||||
selfDeaf: deafened,
|
||||
serverMute: serverMuted,
|
||||
serverDeaf: serverDeafened,
|
||||
camera: cameraEnabled,
|
||||
screen: screenEnabled,
|
||||
quality: media?.quality ?? null,
|
||||
participant: media,
|
||||
});
|
||||
}
|
||||
return occupants;
|
||||
}, [
|
||||
channelId,
|
||||
states,
|
||||
mediaParticipants,
|
||||
connectedHere,
|
||||
directory,
|
||||
sessionUser,
|
||||
micEnabled,
|
||||
deafened,
|
||||
cameraEnabled,
|
||||
screenEnabled,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Права на голосовую модерацию для текущего пользователя. */
|
||||
export function useVoiceModeration(guildId: string | null): VoiceModerationPermissions {
|
||||
const currentUser = useCurrentUser();
|
||||
const guild = useSessionStore((state) =>
|
||||
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||
);
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
const isAdmin =
|
||||
sessionUser?.is_instance_admin === true || currentUser.data?.is_instance_admin === true;
|
||||
if (guild === null) {
|
||||
return { mute: false, deafen: false, move: false };
|
||||
}
|
||||
return voiceModerationPermissions(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
sessionUser?.id ?? currentUser.data?.id,
|
||||
isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
/** Голосовые комнаты сервера: цель для перемещения участников. */
|
||||
export function useVoiceChannels(guildId: string | null): Channel[] {
|
||||
const guild = useSessionStore((state) =>
|
||||
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
(guild?.channels ?? [])
|
||||
.filter((channel) => channel.type === 'voice' && channel.can_view !== false)
|
||||
.sort((left, right) => left.position - right.position),
|
||||
[guild],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
/**
|
||||
* MIME-тип перетаскивания участника голосовой комнаты (AGENT.md 7.14):
|
||||
* плитка в области контента или строка в сайдбаре → голосовая комната.
|
||||
*/
|
||||
export const VOICE_DRAG_MIME = 'application/x-glchat-voice-user';
|
||||
|
||||
/** id участника из события перетаскивания (пусто, если тащат что-то другое). */
|
||||
export function draggedUserId(event: DragEvent<HTMLElement>): string {
|
||||
return event.dataTransfer.getData(VOICE_DRAG_MIME);
|
||||
}
|
||||
|
||||
/** Разрешает сброс только для перетаскивания участника. */
|
||||
export function allowVoiceDrop(event: DragEvent<HTMLElement>): void {
|
||||
if (event.dataTransfer.types.includes(VOICE_DRAG_MIME)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user