Files
glchat/web/src/components/voice/VoiceOccupantRow.tsx
T
grendervill 3fe634c9bc feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер
  инстанса» (лимиты профиля) и «Сервер целиком» (вся машина);
- голосовая комната: рядом с качеством связи видно пинг участника
  («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT
  измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit
  (`glchat.latency`, lossy), поэтому пинг виден для каждого участника;
- статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был
  недоступен (отчёт читался только с видео);
- в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»:
  выключенный не опрашивает SDK и не публикует своё значение;
- тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника,
  публикация по служебной теме, выключенный пинг не дёргает SDK.
2026-09-20 22:10:17 +03:00

109 lines
4.5 KiB
TypeScript

import { useCallback, useRef, useState, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '@/components/ui/Avatar';
import { VoiceFlags, VoiceQualityDot } 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 { speakingRingClass } from '@/components/voice/voiceStyles';
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 latencies = useVoiceConnectionStore((state) => state.latencies);
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);
}}
onContextMenu={(event) => {
// ПКМ — привычный жест для меню участника (AGENT.md 7.14).
event.preventDefault();
setOpen(true);
}}
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} latencyMs={latencies[occupant.userId]} />
</button>
<VoiceOccupantMenu
open={open}
occupant={occupant}
anchorRef={anchorRef}
onClose={close}
permissions={permissions}
moveTargets={moveTargets}
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>
);
}