diff --git a/backend/api/admin.py b/backend/api/admin.py index 879063b..a3f4a3b 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -456,6 +456,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) - """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" return SettingsOut( chat_enabled=cfg.chat.enabled, + hand_queue_enabled=cfg.hand_queue.enabled, transcription_enabled=cfg.transcriber.enabled, ai_level=cfg.ai_level, ai_levels=detect_ai_levels(cfg), diff --git a/backend/api/chat.py b/backend/api/chat.py index d570ef1..357886e 100644 --- a/backend/api/chat.py +++ b/backend/api/chat.py @@ -71,6 +71,7 @@ async def chat_websocket( except ChatAuthError as exc: await _close_quietly(websocket, exc.close_code) return + hand_queue_enabled = await service.hand_queue_enabled() pubsub = redis_client.pubsub() channel = chat_channel(conference.id) @@ -94,7 +95,11 @@ async def chat_websocket( async with asyncio.TaskGroup() as tg: tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids)) - tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity)) + tg.create_task( + _pump_websocket_to_service( + websocket, service, conference, identity, hand_queue_enabled + ) + ) except* WebSocketDisconnect: # Штатное закрытие соединения клиентом — не ошибка. pass @@ -163,9 +168,20 @@ async def _pump_pubsub_to_websocket( async def _pump_websocket_to_service( - websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity + websocket: WebSocket, + service: ChatService, + conference: Conference, + identity: ChatIdentity, + hand_queue_enabled: bool, ) -> None: - """Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.""" + """Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать. + + `hand_queue_enabled` — снятый один раз при подключении тоггл модуля + «поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False` + `raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая + линия защиты сверх того, что фронт при выключенном модуле вообще не + рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`). + """ is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id while True: raw = await websocket.receive_text() @@ -178,11 +194,21 @@ async def _pump_websocket_to_service( if isinstance(envelope, ChatMessageIn): await service.persist_and_publish(conference, identity=identity, text=envelope.text) elif isinstance(envelope, RaiseHandIn): + if not hand_queue_enabled: + await websocket.send_json( + ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json") + ) + continue await hand_queue.raise_hand( conference.id, identity=_identity_key(identity), name=identity.author_name ) await hand_queue.publish_snapshot(conference.id) else: + if not hand_queue_enabled: + await websocket.send_json( + ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json") + ) + continue target = envelope.identity or _identity_key(identity) if target != _identity_key(identity) and not is_organizer: await websocket.send_json( diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py index 46cc8f1..d7eaa21 100644 --- a/backend/core/plugins/config.py +++ b/backend/core/plugins/config.py @@ -33,12 +33,19 @@ class ChatConfig(BaseModel): enabled: bool = True +class HandQueueConfig(BaseModel): + """Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком).""" + + enabled: bool = True + + class PluginsConfig(BaseModel): """Корневая модель конфигурации для `config/plugins.yaml`.""" transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig) summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig) chat: ChatConfig = Field(default_factory=ChatConfig) + hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig) def load_plugins_config(path: str | Path) -> PluginsConfig: @@ -92,6 +99,7 @@ class InstanceConfig(BaseModel): transcriber: TranscriberConfig summarizer: SummarizerConfig chat: ChatConfig + hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig) ai_level: AiLevel = "min" summary_recipients: SummaryRecipientsMode = "all" display_timezone: str = "Europe/Moscow" diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index 4730cf0..ce0d635 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -147,6 +147,7 @@ class SettingsOut(BaseModel): """ chat_enabled: bool + hand_queue_enabled: bool transcription_enabled: bool ai_level: AiLevel ai_levels: list[AiLevelStatus] diff --git a/backend/schemas/conferences.py b/backend/schemas/conferences.py index 06bcb1e..0dbd4e7 100644 --- a/backend/schemas/conferences.py +++ b/backend/schemas/conferences.py @@ -130,6 +130,9 @@ class JoinOut(BaseModel): # Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # показывать ли UI чата, не дожидаясь ошибки WS-подключения. chat_enabled: bool + # Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль + # «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`. + hand_queue_enabled: bool # Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются # прямо в join-ответе, а не только в админке: участнику нужно иметь их # на руках ДО публикации своего трека (см. `services/conference_access.py`). diff --git a/backend/services/chat.py b/backend/services/chat.py index aad114a..c13c8f6 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -148,6 +148,15 @@ class ChatService: raise WrongRoomError return conference + async def hand_queue_enabled(self) -> bool: + """Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS + (см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же + осознанная «застылость» на время жизни соединения, что и у + `is_organizer` в `_pump_websocket_to_service` — переключение модуля + администратором применяется со следующего подключения.""" + cfg = await InstanceSettingsService(self._session).get() + return cfg.hand_queue.enabled + async def history(self, conference: Conference) -> list[ChatMessageOut]: """Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет).""" session_record = await self._sessions.get_open_by_conference(conference.id) diff --git a/backend/services/conference_access.py b/backend/services/conference_access.py index bf075dc..722cec3 100644 --- a/backend/services/conference_access.py +++ b/backend/services/conference_access.py @@ -53,6 +53,7 @@ def build_join( identity: str, name: str, chat_enabled: bool, + hand_queue_enabled: bool, publish_quality_cap: PublishQualityCap, stage_max_tiles: StageMaxTiles, avatar_url: str | None = None, @@ -61,9 +62,10 @@ def build_join( """Построить ответ join: LiveKit access-токен для входа в комнату конференции. Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). - `chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей - стороной значения `instance_settings`: читаются здесь параметрами, а не - заново из БД, чтобы не плодить отдельный запрос настроек на каждый join. + `chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles` + — снятые вызывающей стороной значения `instance_settings`: читаются здесь + параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек + на каждый join. `avatar_url`/`is_organizer` прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` — поля добавляются, только если заданы (гость без аватара и не-организатор @@ -91,6 +93,7 @@ def build_join( room_name=conference.slug, conference_id=conference.id, chat_enabled=chat_enabled, + hand_queue_enabled=hand_queue_enabled, publish_quality_cap=publish_quality_cap, stage_max_tiles=stage_max_tiles, ) diff --git a/backend/services/conferences.py b/backend/services/conferences.py index 1590065..875e09b 100644 --- a/backend/services/conferences.py +++ b/backend/services/conferences.py @@ -155,6 +155,7 @@ class ConferenceService: identity=str(owner_id), name=owner_name, chat_enabled=cfg.chat.enabled, + hand_queue_enabled=cfg.hand_queue.enabled, publish_quality_cap=cfg.media_limits.publish_quality_cap, stage_max_tiles=cfg.media_limits.stage_max_tiles, avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), @@ -251,6 +252,7 @@ class ConferenceService: identity=str(user.id), name=user.name_user, chat_enabled=cfg.chat.enabled, + hand_queue_enabled=cfg.hand_queue.enabled, publish_quality_cap=cfg.media_limits.publish_quality_cap, stage_max_tiles=cfg.media_limits.stage_max_tiles, avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), @@ -275,6 +277,7 @@ class ConferenceService: identity=f"guest:{guest.id}", name=data.display_name, chat_enabled=cfg.chat.enabled, + hand_queue_enabled=cfg.hand_queue.enabled, publish_quality_cap=cfg.media_limits.publish_quality_cap, stage_max_tiles=cfg.media_limits.stage_max_tiles, ) diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index 1f714df..7e8dc4f 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -1,7 +1,7 @@ """Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, -`ai_level`, `summary_recipients`, `display_timezone`, +`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`, `registration_team_choice`, `registration_email_domain`, `contact_email`, `media_limits`) — новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) @@ -28,6 +28,7 @@ from core.config import Settings from core.plugins.config import ( AiLevel, ChatConfig, + HandQueueConfig, InstanceConfig, MediaLimitsConfig, PluginsConfig, @@ -45,6 +46,7 @@ from services.ai_tiers import TIERS _KEY_TRANSCRIBER = "transcriber" _KEY_SUMMARIZER = "summarizer" _KEY_CHAT = "chat" +_KEY_HAND_QUEUE = "hand_queue" _KEY_AI_LEVEL = "ai_level" _KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_DISPLAY_TIMEZONE = "display_timezone" @@ -100,6 +102,7 @@ class SettingsUpdateIn(BaseModel): """ chat_enabled: bool | None = None + hand_queue_enabled: bool | None = None transcription_enabled: bool | None = None ai_level: AiLevel | None = None summary_recipients: SummaryRecipientsMode | None = None @@ -154,6 +157,7 @@ def build_bootstrap_defaults( _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"), _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"), _KEY_CHAT: plugins.chat.model_dump(mode="json"), + _KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"), _KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE), _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), @@ -286,6 +290,10 @@ class InstanceSettingsService: cfg.chat = ChatConfig(enabled=patch.chat_enabled) await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json")) + if patch.hand_queue_enabled is not None: + cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled) + await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json")) + if patch.registration_team_choice is not None: cfg.registration_team_choice = patch.registration_team_choice await self._set( @@ -399,6 +407,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig: transcriber=plugins.transcriber, summarizer=plugins.summarizer, chat=plugins.chat, + hand_queue=plugins.hand_queue, ) else: cfg = _build_config(rows) @@ -492,6 +501,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig: transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})), summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})), chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})), + hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})), ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"), summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get( "mode", "all" diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index e45b61f..765f56d 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -646,12 +646,17 @@ async def test_put_settings_partial_update( response = await client.put( "/api/v1/admin/settings", - json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"}, + json={ + "chat_enabled": False, + "hand_queue_enabled": False, + "display_timezone": "Asia/Yekaterinburg", + }, headers=_auth_headers(admin), ) assert response.status_code == 200, response.text body = response.json() assert body["chat_enabled"] is False + assert body["hand_queue_enabled"] is False assert body["display_timezone"] == "Asia/Yekaterinburg" assert body["ai_level"] == "min" assert body["transcription_queue_served"] is False diff --git a/backend/tests/test_hand_queue_ws.py b/backend/tests/test_hand_queue_ws.py index f09dc1a..1755085 100644 --- a/backend/tests/test_hand_queue_ws.py +++ b/backend/tests/test_hand_queue_ws.py @@ -8,13 +8,15 @@ import uuid from collections.abc import Callable from typing import Any +import httpx from sqlalchemy.ext.asyncio import AsyncSession -from core.security import hash_password +from core.security import create_access_token, hash_password from models.conference import Conference from models.guest import GuestAccess from models.user import User from services.conference_ids import generate_number, generate_slug +from services.instance_settings import InstanceSettingsService, SettingsUpdateIn from services.livekit_tokens import create_room_access_token from tests.conftest import ASGIWebSocketSession @@ -270,3 +272,66 @@ async def test_organizer_joining_late_sees_already_raised_hands( initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner)) assert _identities(initial_queue) == [str(alice.id)] + + +# --- Отключаемый модуль (`instance_settings.hand_queue`) --------------------- + + +async def test_hand_queue_disabled_rejects_raise_and_lower( + db_session: AsyncSession, ws_client: WSFactory +) -> None: + """Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/ + `lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется.""" + conference = await _make_conference(db_session) + alice = await _make_user(db_session, name="Alice") + await db_session.commit() + + await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False)) + + ws = ws_client(_chat_path(conference.id)) + await _connect_auth_and_queue(ws, _user_token(conference, alice)) + + await ws.send_json({"type": "raise_hand"}) + error = await ws.receive_json() + assert error == {"type": "error", "code": "hand_queue_disabled"} + + await ws.send_json({"type": "lower_hand"}) + error = await ws.receive_json() + assert error == {"type": "error", "code": "hand_queue_disabled"} + + +async def test_join_out_reflects_hand_queue_enabled_toggle( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + user = await _make_user(db_session, name="Toggle Tester") + await db_session.commit() + headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"} + + response = await client.post( + "/api/v1/conferences", json={"title": "Standup"}, headers=headers + ) + assert response.status_code == 201, response.text + assert response.json()["join"]["hand_queue_enabled"] is True + + await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False)) + + response = await client.post( + "/api/v1/conferences", json={"title": "Standup 2"}, headers=headers + ) + assert response.status_code == 201, response.text + assert response.json()["join"]["hand_queue_enabled"] is False + + +async def test_guest_join_out_reflects_hand_queue_enabled( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + conference = await _make_conference(db_session) + await db_session.commit() + + await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False)) + + response = await client.post( + f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"} + ) + assert response.status_code == 200, response.text + assert response.json()["hand_queue_enabled"] is False diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index c297a24..e784919 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -65,6 +65,7 @@ _MANAGED_KEYS = ( "transcriber", "summarizer", "chat", + "hand_queue", "ai_level", "summary_recipients", "display_timezone", @@ -121,6 +122,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( "transcriber", "summarizer", "chat", + "hand_queue", "ai_level", "summary_recipients", "display_timezone", @@ -132,6 +134,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( cfg = await service.get() assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.ai_level == "min" + # Дефолт обязан сохранять поведение существующих инсталляций — модуль + # «поднятие руки» был доступен всегда, тоггл включён по умолчанию. + assert cfg.hand_queue.enabled is True assert cfg.summary_recipients == "all" assert cfg.display_timezone == "Europe/Moscow" assert cfg.registration_team_choice is False @@ -162,6 +167,22 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( assert cfg.display_timezone == "Asia/Yekaterinburg" +async def test_update_hand_queue_enabled( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + + cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False)) + assert cfg.hand_queue.enabled is False + + cfg = await service.get() + assert cfg.hand_queue.enabled is False + + cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True)) + assert cfg.hand_queue.enabled is True + + @pytest.mark.parametrize( ("preset", "chat_enabled", "ai_enabled", "ai_level"), [ diff --git a/config/plugins.yaml b/config/plugins.yaml index 549ccb0..c5f555c 100644 --- a/config/plugins.yaml +++ b/config/plugins.yaml @@ -33,3 +33,6 @@ summarizer: chat: enabled: true + +hand_queue: + enabled: true diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 46e7827..93e40b9 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -19,6 +19,8 @@ export interface AiLevelStatus { /** Эффективные настройки инстанса. */ export interface SettingsOut { chat_enabled: boolean + /** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */ + hand_queue_enabled: boolean /** Единый переключатель модуля AI (транскрибация + суммаризация). */ transcription_enabled: boolean /** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */ @@ -48,6 +50,7 @@ export interface SettingsOut { /** Тело частичного обновления настроек инстанса — все поля опциональны. */ export interface SettingsUpdateIn { chat_enabled?: boolean + hand_queue_enabled?: boolean transcription_enabled?: boolean /** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */ ai_level?: AiLevel diff --git a/frontend/src/api/conferences.ts b/frontend/src/api/conferences.ts index 5e63f2b..64638ca 100644 --- a/frontend/src/api/conferences.ts +++ b/frontend/src/api/conferences.ts @@ -61,6 +61,8 @@ export interface ConferenceJoinData { conference_id: string /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ chat_enabled: boolean + /** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */ + hand_queue_enabled: boolean /** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */ publish_quality_cap: PublishQualityCap /** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */ diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index e9f0ffc..70872c2 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -68,6 +68,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { const { user } = useAuth() const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) + const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiLevel, setAiLevel] = useState(data.ai_level) const [recipients, setRecipients] = useState(data.summary_recipients) @@ -132,6 +133,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { // валился бы в 400, блокируя правку вообще любой другой настройки. const payload: SettingsUpdateIn = {} if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled + if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (recipients !== data.summary_recipients) payload.summary_recipients = recipients @@ -175,6 +177,21 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { +
+
+ Поднятие руки + Кнопка «Рука» и очередь поднятых рук в комнате — целиком +
+ +
+
Транскрибация и суммаризация (AI) diff --git a/frontend/src/components/room/HandQueueMenu.tsx b/frontend/src/components/room/HandQueueMenu.tsx index f0da65d..bbf2b7f 100644 --- a/frontend/src/components/room/HandQueueMenu.tsx +++ b/frontend/src/components/room/HandQueueMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Hand, ListOrdered } from 'lucide-react' +import { useLocalParticipant } from '@livekit/components-react' import { useIsOrganizer } from '@/hooks/useIsOrganizer' import type { HandQueueEntry } from '@/hooks/useChat' @@ -9,8 +10,15 @@ interface HandQueueMenuProps { } /** - * Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору - * (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»): + * Кнопка «Очередь» в тулбаре с поповером над ней — видна ВСЕМ участникам + * (сессия «28-hand-queue-for-all»: раньше очередь видел только организатор). + * Опустить чужую запись может по-прежнему только организатор — обычный + * участник видит кнопку «Опустить» только напротив СВОЕЙ записи (или не + * видит её вовсе, если сам руку не поднимал): сервер (`api/chat.py`) всё + * равно отклонит попытку опустить чужую руку кодом `forbidden`, но мёртвая + * кнопка, которая молча не работает, хуже отсутствующей. + * + * Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»): * собственное состояние открытия, закрытие по клику вне/Escape, поповер * `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат): * очередь рук — короткий список, а не история переписки, разворачивать её @@ -19,11 +27,12 @@ interface HandQueueMenuProps { * Размер поповера подстраивается под число записей — `.hand-queue-list` * растёт вместе со списком и не даёт пустого места при 1–2 поднятых руках, * но не бесконечно: после ~10 строк список упирается в `max-height` и дальше - * скроллится (см. room.css) — иначе организатор на энергичной встрече - * получил бы поповер выше экрана. + * скроллится (см. room.css) — иначе участник на энергичной встрече получил + * бы поповер выше экрана. */ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) { const isOrganizer = useIsOrganizer() + const { localParticipant } = useLocalParticipant() const [open, setOpen] = useState(false) const wrapRef = useRef(null) @@ -47,8 +56,6 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) { } }, [open]) - if (!isOrganizer) return null - return (
- - ))} + {queue.map((entry, index) => { + const isOwn = entry.identity === localParticipant.identity + const canLower = isOrganizer || isOwn + return ( +
  • + {index + 1} + + + {canLower && ( + + )} +
  • + ) + })} )}
    diff --git a/frontend/src/components/room/RoomToolbar.tsx b/frontend/src/components/room/RoomToolbar.tsx index c9a68e6..b385a04 100644 --- a/frontend/src/components/room/RoomToolbar.tsx +++ b/frontend/src/components/room/RoomToolbar.tsx @@ -17,7 +17,6 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { useToast } from '@/components/ui/ToastProvider' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' -import { useIsOrganizer } from '@/hooks/useIsOrganizer' import type { HandQueueEntry } from '@/hooks/useChat' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { HandQueueMenu } from '@/components/room/HandQueueMenu' @@ -59,6 +58,8 @@ interface RoomToolbarProps extends StageViewProps { pipSupported: boolean pipActive: boolean onTogglePiP: () => void + /** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */ + handQueueEnabled: boolean /** * Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама * решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity` @@ -94,9 +95,11 @@ interface RoomToolbarProps extends StageViewProps { * (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой * экономии: поднять руку посреди разговора — действие со временем жизни в * секунды, прятать его в шторку настроек означало бы делать его практически - * недоступным с телефона. «Очередь» показывается только организатору — - * встречается редко, но по той же причине оставлена в тулбаре, а не в - * шторке: организатору с телефона тоже нужно видеть очередь сразу. + * недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия + * «28-hand-queue-for-all»: раньше только организатору), по той же причине + * оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно + * видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled` + * (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке). * Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку, * как демонстрация/мини-плеер) — по решению оператора вход в него должен * быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один @@ -115,6 +118,7 @@ export function RoomToolbar({ pipSupported, pipActive, onTogglePiP, + handQueueEnabled, handQueue, onRaiseHand, onLowerHand, @@ -128,21 +132,22 @@ export function RoomToolbar({ }: RoomToolbarProps) { const toast = useToast() const isCompact = useIsCompactViewport() - const isOrganizer = useIsOrganizer() const { localParticipant } = useLocalParticipant() const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) // Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там // скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) — // считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди» - // (только у организатора) в DOM есть своя обёртка `.tb-menu-wrap`, из-за - // которой позиция остальных кнопок «плывёт», и подсчёт по структуре DOM - // был бы хрупким. От порога зависит `.tb-wrap-grid` в room.css — ниже он - // переключает перенос на 2 строки с «как поместится» (flex-wrap) на - // равномерную сетку 4 колонки (7 → 4+3, 8 → 4+4). + // в DOM есть своя обёртка `.tb-menu-wrap`, из-за которой позиция остальных + // кнопок «плывёт», и подсчёт по структуре DOM был бы хрупким. От порога + // зависит `.tb-wrap-grid` в room.css — ниже он переключает перенос на + // 2 строки с «как поместится» (flex-wrap) на равномерную сетку 4 колонки + // (7 → 4+3, 8 → 4+4). «Рука»/«Очередь» — обе теперь видны любому участнику + // (сессия «28-hand-queue-for-all»), их наличие зависит только от + // `handQueueEnabled`, не от роли. const mobileButtonCount = - 3 /* микрофон, камера, рука */ + - (isOrganizer ? 1 : 0) /* очередь */ + + 2 /* микрофон, камера */ + + (handQueueEnabled ? 2 : 0) /* рука, очередь */ + 1 /* настройки */ + (fullscreenSupported ? 1 : 0) + (chatVisible ? 1 : 0) + @@ -214,23 +219,25 @@ export function RoomToolbar({ Демонстрация - + {handQueueEnabled && ( + + )} - + {handQueueEnabled && } {!isCompact && ( new Set(chat.handQueue.map((entry) => entry.identity)), - [chat.handQueue], + () => + joinState?.handQueueEnabled + ? new Set(chat.handQueue.map((entry) => entry.identity)) + : new Set(), + [chat.handQueue, joinState?.handQueueEnabled], ) // Корневой контейнер комнаты — цель для fullscreen и источник video-элемента @@ -528,6 +537,7 @@ export function RoomPage() { pipSupported={pip.supported} pipActive={pip.active} onTogglePiP={pip.toggle} + handQueueEnabled={Boolean(joinState.handQueueEnabled)} handQueue={chat.handQueue} onRaiseHand={chat.raiseHand} onLowerHand={() => chat.lowerHand()}