fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
@@ -6,6 +6,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { fetchGuild, guildQueryKey } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { GatewayBridge } from '@/components/GatewayBridge';
|
||||
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
|
||||
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
@@ -193,6 +194,8 @@ export default function AppLayout() {
|
||||
</div>
|
||||
|
||||
<CreateGuildModal />
|
||||
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
|
||||
<VoiceSpeakingNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Field, SelectField } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { VoiceChannelItem } from '@/components/voice/VoiceChannelItem';
|
||||
import { groupChannels } from '@/lib/channels';
|
||||
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
@@ -196,11 +197,19 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
<ul className="gl-stagger flex flex-col gap-0.5">
|
||||
{group.channels.map((channel) => (
|
||||
<li key={channel.id}>
|
||||
<ChannelLink
|
||||
guildId={guildId}
|
||||
channel={channel}
|
||||
selected={channel.id === channelId}
|
||||
/>
|
||||
{channel.type === 'voice' ? (
|
||||
<VoiceChannelItem
|
||||
guildId={guildId}
|
||||
channel={channel}
|
||||
selected={channel.id === channelId}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLink
|
||||
guildId={guildId}
|
||||
channel={channel}
|
||||
selected={channel.id === channelId}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -7,14 +7,14 @@ 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';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
||||
* для голосовых сохраняется заглушка до следующих фаз.
|
||||
* для голосовых — панель голосовой комнаты (AGENT.md 7.14).
|
||||
*/
|
||||
export default function GuildView() {
|
||||
const { t } = useTranslation();
|
||||
@@ -116,14 +116,7 @@ export default function GuildView() {
|
||||
</header>
|
||||
|
||||
{isVoice ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center">
|
||||
<h3 className="text-lg font-semibold">{t('channels.voicePlaceholder.title')}</h3>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
{t('channels.voicePlaceholder.body', { name: channel.name })}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<VoiceRoomPanel guildId={guild?.id ?? ''} channel={channel} />
|
||||
) : (
|
||||
<>
|
||||
<SearchPanel
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { SettingsModal } from '@/pages/settings/SettingsModal';
|
||||
|
||||
/** Статусы, которые можно выставить из мини-профиля: «не в сети» сервер выводит сам. */
|
||||
@@ -52,6 +53,10 @@ interface PanelControlProps {
|
||||
toggleLabel: string;
|
||||
/** Подпись стрелки и поповера. */
|
||||
menuLabel: string;
|
||||
/** Заблокировано модератором: переключать нельзя. */
|
||||
disabled?: boolean;
|
||||
/** Почему кнопка заблокирована (озвучивается вместо подписи). */
|
||||
disabledReason?: string;
|
||||
onToggle: () => void;
|
||||
open: boolean;
|
||||
onOpenMenu: () => void;
|
||||
@@ -69,6 +74,8 @@ function PanelControl({
|
||||
enabled,
|
||||
toggleLabel,
|
||||
menuLabel,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
onToggle,
|
||||
open,
|
||||
onOpenMenu,
|
||||
@@ -78,6 +85,7 @@ function PanelControl({
|
||||
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
|
||||
// Группа «стрелка + кнопка»: поповер прижимается к её краю.
|
||||
const groupRef: RefObject<HTMLDivElement | null> = useRef(null);
|
||||
const label = disabled && disabledReason !== undefined ? disabledReason : toggleLabel;
|
||||
|
||||
return (
|
||||
<div ref={groupRef} className="relative flex h-8 w-9 shrink-0 items-stretch gap-0">
|
||||
@@ -100,10 +108,11 @@ function PanelControl({
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-pressed={enabled}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
|
||||
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
enabled ? 'text-fg' : 'text-danger'
|
||||
}`}
|
||||
>
|
||||
@@ -144,9 +153,6 @@ export function UserPanel() {
|
||||
const micEnabled = useVoiceStore((state) => state.micEnabled);
|
||||
const cameraEnabled = useVoiceStore((state) => state.cameraEnabled);
|
||||
const headphonesEnabled = useVoiceStore((state) => state.headphonesEnabled);
|
||||
const toggleMic = useVoiceStore((state) => state.toggleMic);
|
||||
const toggleCamera = useVoiceStore((state) => state.toggleCamera);
|
||||
const toggleHeadphones = useVoiceStore((state) => state.toggleHeadphones);
|
||||
const micVolume = useVoiceStore((state) => state.micVolume);
|
||||
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
||||
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
|
||||
@@ -155,6 +161,22 @@ export function UserPanel() {
|
||||
const camera = useDeviceChoice('videoinput');
|
||||
const output = useDeviceChoice('audiooutput');
|
||||
|
||||
// Пока не подключены к голосовой комнате, кнопки хранят локальные
|
||||
// предпочтения; в комнате они управляют настоящей сессией LiveKit.
|
||||
const connectionStatus = useVoiceConnectionStore((state) => state.status);
|
||||
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
||||
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
||||
const connectionMic = useVoiceConnectionStore((state) => state.micEnabled);
|
||||
const connectionCamera = useVoiceConnectionStore((state) => state.cameraEnabled);
|
||||
const connectionDeafened = useVoiceConnectionStore((state) => state.deafened);
|
||||
const setSessionMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
|
||||
const setSessionCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||
const setSessionDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
const connected = connectionStatus === 'connected' || connectionStatus === 'reconnecting';
|
||||
const micOn = connected ? connectionMic : micEnabled;
|
||||
const cameraOn = connected ? connectionCamera : cameraEnabled;
|
||||
const soundOn = connected ? !connectionDeafened : headphonesEnabled;
|
||||
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
/** Какое устройство сейчас раскрыто в поповере: одновременно только одно. */
|
||||
const [openControl, setOpenControl] = useState<'mic' | 'camera' | 'headphones' | null>(null);
|
||||
@@ -175,6 +197,8 @@ export function UserPanel() {
|
||||
onSuccess: () => {
|
||||
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
|
||||
disconnect();
|
||||
// Голосовую комнату покидаем без запросов: сессии больше нет.
|
||||
useVoiceConnectionStore.getState().reset();
|
||||
useSessionStore.getState().reset();
|
||||
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
|
||||
useMessagesStore.getState().reset();
|
||||
@@ -262,10 +286,12 @@ export function UserPanel() {
|
||||
|
||||
<PanelControl
|
||||
glyph="🎤"
|
||||
enabled={micEnabled}
|
||||
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
|
||||
enabled={micOn && !serverMuted}
|
||||
toggleLabel={t(micOn ? 'user.voice.micOff' : 'user.voice.micOn')}
|
||||
menuLabel={t('user.voice.micSettings')}
|
||||
onToggle={toggleMic}
|
||||
disabled={serverMuted}
|
||||
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
|
||||
onToggle={() => void setSessionMicEnabled(!micOn)}
|
||||
open={openControl === 'mic'}
|
||||
onOpenMenu={() => toggleControl('mic')}
|
||||
onCloseMenu={() => setOpenControl(null)}
|
||||
@@ -286,10 +312,10 @@ export function UserPanel() {
|
||||
|
||||
<PanelControl
|
||||
glyph="🎥"
|
||||
enabled={cameraEnabled}
|
||||
toggleLabel={t(cameraEnabled ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
|
||||
enabled={cameraOn}
|
||||
toggleLabel={t(cameraOn ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
|
||||
menuLabel={t('user.voice.cameraSettings')}
|
||||
onToggle={toggleCamera}
|
||||
onToggle={() => void setSessionCameraEnabled(!cameraOn)}
|
||||
open={openControl === 'camera'}
|
||||
onOpenMenu={() => toggleControl('camera')}
|
||||
onCloseMenu={() => setOpenControl(null)}
|
||||
@@ -305,10 +331,12 @@ export function UserPanel() {
|
||||
|
||||
<PanelControl
|
||||
glyph="🎧"
|
||||
enabled={headphonesEnabled}
|
||||
toggleLabel={t(headphonesEnabled ? 'user.voice.soundOff' : 'user.voice.soundOn')}
|
||||
enabled={soundOn && !serverDeafened}
|
||||
toggleLabel={t(soundOn ? 'user.voice.soundOff' : 'user.voice.soundOn')}
|
||||
menuLabel={t('user.voice.soundSettings')}
|
||||
onToggle={toggleHeadphones}
|
||||
disabled={serverDeafened}
|
||||
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
|
||||
onToggle={() => void setSessionDeafened(soundOn)}
|
||||
open={openControl === 'headphones'}
|
||||
onOpenMenu={() => toggleControl('headphones')}
|
||||
onCloseMenu={() => setOpenControl(null)}
|
||||
|
||||
Reference in New Issue
Block a user