8 Commits

Author SHA1 Message Date
7b2427535a release: версия 0.0.20
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 12:34:04 +03:00
8e45038251 fix(livekit): один UDP-порт вместо диапазона на 101 порт
Диапазон 54000-54100/udp заставлял Docker поднимать по отдельному
docker-proxy на каждый порт — весь медиатрафик шёл лишним userland-хопом.
rtc.udp_port переключает LiveKit на мультиплексирование ICE-сессий через
один порт; TURN и остальные связи (redis/nginx/webhook/prometheus) не
задеты. Проверено локально lk load-test — 0% потерь пакетов, ICE во всех
сессиях выбирает новый порт.
2026-08-02 12:33:53 +03:00
286c01d93b release: версия 0.0.19
Some checks failed
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
2026-08-02 05:03:05 +03:00
e8af2fce10 fix(room): адаптивный тулбар — плавное сжатие кнопок вместо оверфлоу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Задачи B1/B2 добавили в тулбар «Рука» и «Очередь» (до 11 кнопок вместо
исходных 8) — на промежуточных ширинах (~600–1100px) кнопки вылезали за
края тулбара: жёсткого мобильного брейкпоинта (≤600px, скрывает три
кнопки) не хватало, а до него сжатия не было вовсе.

Иконка/отступы/зазоры/шрифт кнопок теперь плавно уменьшаются на диапазоне
1200px → 600px через `clamp()` с явной линейной интерполяцией (обычный
`clamp(min, Nvw, max)` не подошёл — подобранный N упирался в потолок уже на
довольно широких экранах, сжатие получалось резким скачком заметно раньше
нужной ширины, а не плавным). Нижние границы совпадают с тем, что жёстко
выставляет мобильный медиа-запрос на 600px — переход в него визуально
бесшовный.

Побочный эффект сжатия: подпись «Мини-окно» на промежуточных ширинах
переносилась на 2 строки и делала эту кнопку выше соседних. Ниже 1200px
показываем короткое «Мини» вместо полной подписи — кнопка остаётся
однострочной; aria-label по-прежнему несёт полный смысл для скринридеров.
2026-08-02 05:00:22 +03:00
d7ae462ed5 fix(room): очередь поднятых рук — компактный поповер вместо панели во весь экран
HandQueuePanel рендерился как боковая панель на весь экран на мобильном
(.chat-panel, унаследовано от чата) — для короткого списка поднятых рук это
избыточно, история переписки тут ни при чём. Переделано в HandQueueMenu —
тот же самодостаточный поповер над кнопкой, что и «Вид» (StageViewMenu):
своё состояние открытия, закрытие по клику вне/Escape, .tb-menu вместо
боковой панели. Кнопка «Очередь» больше не получает open-состояние
пропсами сверху — сама решает, открыта ли, и сама проверяет
useIsOrganizer().

Список внутри растёт вместе с очередью (компактно при 1–2 поднятых руках),
но не бесконечно — после ~10 строк упирается в max-height и скроллится
дальше.
2026-08-02 04:58:56 +03:00
826b7639b1 fix(chat): специфичность CSS эмодзи-поповера + сетка 6×5
Кнопка эмодзи и все кнопки внутри поповера лежат в DOM внутри
.chat-input-row (форма отправки) — той же формы, что и круглая зелёная
кнопка «Отправить» (.chat-input-row button, специфичность 0,1,1). Голого
класса .chat-emoji-trigger/.chat-emoji-option (0,1,0) для победы над этим
правилом не хватало: все кнопки поповера красились в зелёный независимо от
порядка объявления в файле — оттуда и «поплывший» вид после добавления
коня, дело было не в самом коне. Каждый селектор уточнён родительским
классом (.chat-emoji-wrap/.chat-emoji-popover), чтобы обойти гонку
специфичности.

Заодно сетка приведена к ровным 5 колонкам × 6 строкам — добавлены
🦾 🚀 🦞 💯 🤷‍♂️, теперь 30 эмодзи без неполной последней строки.
2026-08-02 04:57:39 +03:00
daaa480f03 release: версия 0.0.18
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:42:53 +03:00
c60047c594 fix(conferences): rate limit блокировал вход всей конференции сразу
Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026.

1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx,
   поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей.
   Проверено на проде: в Redis лежал единственный ключ
   `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту»
   действовал на весь инстанс разом, а не на клиента.

2. Считались все запросы подряд, включая успешные. Одиннадцатый человек,
   открывший ссылку на конференцию в течение минуты, получал 429 — и видел
   «Не удалось найти конференцию» для существующей и активной конференции.
   Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно.

Что изменилось:
- адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно
  `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется
  через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному
  клиентом, и лимит обходился бы одним заголовком;
- жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ:
  конференция не найдена или пароль неверен. Именно так выглядит перебор
  номера, от которого лимит и защищает по ADR-001, п.4;
- на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого
  флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше
  правдоподобного числа участников одной конференции.

Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30
подряд); перебор номера, несуществующий идентификатор и подбор пароля
по-прежнему упираются; лимит одного клиента не задевает другого.
2026-08-01 23:42:36 +03:00
16 changed files with 472 additions and 163 deletions

View File

@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.17 VIDCONF_VERSION=0.0.20
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,56 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.20] — 2026-08-02
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
### Исправлено
- Весь медиа-трафик конференций шёл через userland-прокси Docker: диапазон
`54000-54100/udp` заставлял поднимать по отдельному процессу `docker-proxy`
на каждый порт. LiveKit переведён на `rtc.udp_port` (один порт,
мультиплексирование ICE-сессий по ufrag внутри самого сервера) — проброс
портов схлопнут до одного, TURN не затронут. Проверено локально
синтетической нагрузкой (`lk load-test`, 2 видео + 2 аудио publisher'а,
2 subscriber'а) — 0% потерь пакетов, ICE во всех сессиях выбирает новый
единственный порт.
## [0.0.19] — 2026-08-02
Правки по замечаниям к части B (комната конференции).
### Исправлено
- Очередь поднятых рук открывалась боковой панелью во весь экран на
мобильном — теперь компактный поповер над кнопкой (как «Вид»), размер
подстраивается под число записей, после ~10 строк список скроллится.
- Кнопки нижнего тулбара при сужении окна вылезали за края блока (задачи
B1/B2 добавили «Рука»/«Очередь», в тулбаре стало до 11 кнопок вместо
восьми) — теперь плавно уменьшаются на диапазоне 1200600px вместо
жёсткого скачка на мобильный вид.
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 2 строки и
делала эту кнопку выше соседних — ниже 1200px показывается короткое
«Мини».
- Эмодзи-поповер в чате красил все свои кнопки в зелёный цвет кнопки
«Отправить» (гонка специфичности CSS-селекторов) — исправлено; заодно
сетка приведена к ровным 5×6 без неполной строки, добавлены
🦾 🚀 🦞 💯 🤷‍♂️.
## [0.0.18] — 2026-08-01
Ограничение частоты запросов больше не блокирует вход целой конференции.
### Исправлено
- Лимит на резолв конференции и гостевой вход считался по адресу контейнера
nginx, а не клиента, — то есть «10 запросов в минуту» действовали на весь
сервер разом. Одиннадцатый человек, открывший ссылку в течение минуты,
получал отказ и видел «Не удалось найти конференцию» для существующей и
активной конференции. Теперь адрес берётся из заголовка, который nginx уже
передаёт.
- Счётчик считает только неудачные попытки — конференция не найдена или
пароль неверен. Именно так выглядит перебор номера, от которого защищает
ограничение; массовый вход по рабочей ссылке к нему отношения не имеет.
На общий поток с адреса оставлен потолок в 300 запросов в минуту.
## [0.0.17] — 2026-08-01 ## [0.0.17] — 2026-08-01
Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля. Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля.

View File

@@ -1 +1 @@
0.0.17 0.0.20

View File

@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user from api.deps import get_current_user
from core.db import get_session from core.db import get_session
from core.rate_limit import enforce_rate_limit from core.rate_limit import (
RATE_LIMIT_MISS_MAX_REQUESTS,
RATE_LIMIT_SOFT_MAX_REQUESTS,
client_ip,
enforce_rate_limit,
)
from models.user import User from models.user import User
from schemas.conferences import ( from schemas.conferences import (
ConferenceCreateIn, ConferenceCreateIn,
@@ -103,10 +108,18 @@ async def resolve_conference(
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
join/guest-join), а признак закрытости неактуален для мёртвой конференции. join/guest-join), а признак закрытости неактуален для мёртвой конференции.
""" """
await enforce_rate_limit(f"resolve:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
# вся конференция открывает ссылку в одну минуту.
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
conference = await service.resolve(q) conference = await service.resolve(q)
if conference is None: if conference is None:
# Жёсткий счётчик — только на промахи: перебор номера конференции
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
await enforce_rate_limit(
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
if conference.status == "ended": if conference.status == "ended":
return ResolveOut(id=conference.id, title=conference.title, status=conference.status) return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
@@ -154,11 +167,15 @@ async def guest_join_conference(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> JoinOut: ) -> JoinOut:
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit.""" """Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
try: try:
return await service.join_as_guest(conference_id, data=data) return await service.join_as_guest(conference_id, data=data)
except ConferenceNotFoundError as exc: except ConferenceNotFoundError as exc:
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc ) from exc
@@ -169,6 +186,9 @@ async def guest_join_conference(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required" status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc ) from exc
except InvalidPasswordError as exc: except InvalidPasswordError as exc:
# Подбор пароля закрытой конференции — тот же класс атаки, что и
# перебор номера, поэтому считается жёстким счётчиком.
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc ) from exc
@@ -284,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
return value.astimezone(UTC) return value.astimezone(UTC)
def _client_ip(request: Request) -> str: async def _count_guest_join_miss(ip: str) -> None:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю).""" """Учесть неудачную попытку гостевого входа в жёстком счётчике.
return request.client.host if request.client else "unknown"
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
(несуществующая конференция и неверный пароль) и обязано бросать 429
ровно так же, как обычный `enforce_rate_limit`.
"""
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)

View File

@@ -3,15 +3,59 @@
Используется резолвом конференций и гостевым входом (`api/conferences.py`) — Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP). (см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
## Два счётчика вместо одного (0.0.18)
Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном
тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку
одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти
конференцию» — при том что конференция существовала и была активна.
Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это
поток промахов; легитимный участник открывает существующую ссылку и получает
успех. Поэтому:
- `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не
найдена, неверный пароль). Именно он защищает от перебора, и он остался
прежним — 10/мин;
- `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с
одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня
человек из офиса за общим NAT спокойно зашла в одну конференцию.
""" """
from fastapi import HTTPException, status from fastapi import HTTPException, Request, status
from core.redis import redis_client from core.redis import redis_client
RATE_LIMIT_MAX_REQUESTS = 10 RATE_LIMIT_MAX_REQUESTS = 10
RATE_LIMIT_WINDOW_SECONDS = 60 RATE_LIMIT_WINDOW_SECONDS = 60
# Промахи: перебор номера/ссылки или подбор пароля конференции.
RATE_LIMIT_MISS_MAX_REQUESTS = 10
# Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок
# заведомо выше правдоподобного числа участников одной конференции.
RATE_LIMIT_SOFT_MAX_REQUESTS = 300
def client_ip(request: Request) -> str:
"""IP клиента для rate limit — с учётом того, что backend стоит за nginx.
`request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же
для всех пользователей. С ним лимит превращался в общий на весь инстанс:
на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`,
и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу.
Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет
его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному
клиентом. Первый элемент там подделывается одним заголовком, и лимит
обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим
`$remote_addr` (см. deploy/nginx/nginx.conf.template).
"""
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
async def enforce_rate_limit( async def enforce_rate_limit(
key: str, key: str,

View File

@@ -573,17 +573,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
assert response.json()["detail"] == "not_found" assert response.json()["detail"] == "not_found"
async def test_resolve_is_rate_limited_after_10_requests_per_minute( def _ip_headers() -> dict[str, str]:
client: httpx.AsyncClient, """Уникальный `X-Real-IP` на каждый тест.
) -> None:
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
"""
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) response = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert response.status_code == 404 assert response.status_code == 404
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) limited = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert limited.status_code == 429 assert limited.status_code == 429
async def test_successful_resolves_are_not_limited_by_miss_counter(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
для существующей и активной конференции.
"""
conference = await _make_conference(db_session)
await db_session.commit()
headers = _ip_headers()
for _ in range(50):
response = await client.get(
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
)
assert response.status_code == 200, response.text
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
работать. До исправления ключ был общим на весь инстанс.
"""
first, second = _ip_headers(), _ip_headers()
for _ in range(11):
await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
exhausted = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
assert exhausted.status_code == 429
other = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
)
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
# --- Вход зарегистрированным пользователем --------------------------------------- # --- Вход зарегистрированным пользователем ---------------------------------------
@@ -835,21 +893,69 @@ async def test_guest_join_ended_conference_returns_410(
assert response.json()["detail"] == "conference_ended" assert response.json()["detail"] == "conference_ended"
async def test_guest_join_is_rate_limited_after_10_requests_per_minute( async def test_guest_join_allows_a_whole_conference_to_enter(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
"""Успешные гостевые входы не упираются в лимит промахов.
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
пускало внутрь именно это ограничение — счётчик не различал легитимный
массовый вход и перебор.
"""
conference = await _make_conference(db_session) conference = await _make_conference(db_session)
await db_session.commit() await db_session.commit()
headers = _ip_headers()
for i in range(30):
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": f"Guest {i}"},
headers=headers,
)
assert response.status_code == 200, response.text
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
headers = _ip_headers()
missing_id = uuid.uuid4()
for _ in range(10):
response = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert response.status_code == 404
limited = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert limited.status_code == 429
async def test_guest_join_wrong_password_is_rate_limited(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
conference = await _make_conference(db_session, is_closed=True, password="right-password")
await db_session.commit()
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.post( response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Repeat Guest"}, json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert response.status_code == 200 assert response.status_code == 403
limited = await client.post( limited = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"} f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert limited.status_code == 429 assert limited.status_code == 429

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.17} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.20}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -407,16 +407,19 @@ services:
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени # 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры # `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не # снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются # нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
# публичными. # публичными.
ports: ports:
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling - "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
- "7881:7881" # RTC TCP fallback - "7881:7881" # RTC TCP fallback
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда # Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop. # мультиплексирует все ICE-сессии через него (rtc.udp_port в
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+) # livekit.yaml.template), а не открывает по порту на участника.
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения. # На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
- "54000-54100:54000-54100/udp" # WebRTC media (ICE) # 101 порт держали 101 лишний userland-процесс на медиапути.
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
# macOS заняты Steam/системными процессами и эфемерными QUIC.
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy

View File

@@ -15,16 +15,20 @@ port: 7880
rtc: rtc:
tcp_port: 7881 tcp_port: 7881
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде # Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
# расширить и синхронизировать с пробросом портов. # Раньше здесь был port_range_start/port_range_end (54000-54100) — под
port_range_start: 54000 # каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
port_range_end: 54100 # (userland-прокси на весь медиатрафик), на 101 порт — 101 процесс.
# udp_port переключает LiveKit на единственный сокет с демультиплексацией
# по ICE ufrag; port_range_start/end при заданном udp_port игнорируются
# (проверено по исходникам сервера) — оставлять их рядом бессмысленно.
udp_port: 54000
# use_external_ip: false + node_ip=127.0.0.1 — режим для локальной # use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
# разработки (Docker Desktop): use_external_ip=true определяет публичный # разработки (Docker Desktop): use_external_ip=true определяет публичный
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста # IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит # внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты # ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты
# 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент # 7881/tcp и 54000/udp проброшены на loopback хоста, а браузер-клиент
# запускается на том же хосте. # запускается на том же хосте.
# В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен # В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу — # сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —

View File

@@ -81,7 +81,7 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge) ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media) ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта: # TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из # сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но # deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
@@ -276,7 +276,7 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
# 1. Все сервисы healthy # 1. Все сервисы healthy
docker compose -f deploy/docker-compose.yml --env-file .env ps docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100, # 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp, если включили TURN — раздел 8) # плюс 3478 tcp+udp, если включили TURN — раздел 8)
ss -ltnp ss -ltnp
@@ -318,7 +318,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в **Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1, `LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
firewall) хватает для подавляющего большинства сетей. Включайте этот firewall) хватает для подавляющего большинства сетей. Включайте этот
раздел только если у вас есть конкретные пользователи за CGNAT или раздел только если у вас есть конкретные пользователи за CGNAT или
жёстким корпоративным firewall, которые не могут установить медиа-соединение жёстким корпоративным firewall, которые не могут установить медиа-соединение

View File

@@ -179,12 +179,15 @@ dev-стека.
профилей битрейта заводить не требуется; при необходимости ограничить профилей битрейта заводить не требуется; при необходимости ограничить
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
(клиентский SDK, вне скоупа devops-части). (клиентский SDK, вне скоупа devops-части).
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной 4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
ступени (максимум 60 участников в тесте) — при планировании прод-узла с не был узким местом ни на одной ступени (максимум 60 участников). Тогда
ожидаемым бОльшим числом одновременных участников across все комнаты же с ним была цена: под каждый порт диапазона Docker держал отдельный
узла держать `port_range_end - port_range_start` заметно больше пикового процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
числа участников на узле (LiveKit резервирует пару портов на участника шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
на медиа-транспорт). мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
диапазон шире пикового числа участников» больше не актуальна — портов
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
поверх одного сокета сам.
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей 5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
проде, а не только для теста — иначе клиенты в вырожденном случае проде, а не только для теста — иначе клиенты в вырожденном случае

View File

@@ -6,13 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
/** /**
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на * Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
* сотни килобайт ради десятка кнопок в поповере. * сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
* добавляя/убирая эмодзи, держи кратность 5).
*/ */
const EMOJI_OPTIONS = [ const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢', '😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️', '😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝', '🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
'🐎', '🐎', '🦾', '🚀', '🦞', '💯', '🤷‍♂️',
] ]
interface ChatPanelProps { interface ChatPanelProps {

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueueMenuProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
}
/**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её
* во весь экран незачем и на мобильном.
*
* Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече
* получил бы поповер выше экрана.
*/
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer()
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [open])
if (!isOrganizer) return null
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
type="button"
className={`tb-btn${open ? ' is-panel-open' : ''}`}
aria-pressed={open}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={() => setOpen((v) => !v)}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{queue.length > 0 && (
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
{open && (
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</div>
)}
</div>
)
}

View File

@@ -1,60 +0,0 @@
import { Hand, X } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueuePanelProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
onClose: () => void
}
/**
* Панель очереди поднятых рук — видна только организатору (задача B1).
* Визуально — тот же боковой контейнер, что и `ChatPanel` (`.chat-panel`,
* включая мобильное поведение «во весь экран» на ≤900px), содержимое своё:
* упорядоченный список с позицией и кнопкой «Опустить» на каждой строке —
* организатору разрешено опускать чужую руку (решение оператора, задача B1).
*
* `RoomPage` гейтит рендер по `handQueueOpen` (как и `ChatPanel` по
* `chatOpen`) — свой `useIsOrganizer()` здесь ДОПОЛНИТЕЛЬНАЯ, а не
* единственная защита: `RoomToolbar` уже не показывает кнопку открытия
* не-организатору, это подстраховка на случай прямого рендера.
*/
export function HandQueuePanel({ queue, onLower, onClose }: HandQueuePanelProps) {
const isOrganizer = useIsOrganizer()
if (!isOrganizer) return null
return (
<aside className="chat-panel hand-queue-panel">
<div className="chat-head">
<h2>Поднятые руки</h2>
<button type="button" aria-label="Закрыть очередь" onClick={onClose}>
<X className="lucide" aria-hidden="true" />
</button>
</div>
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</aside>
)
}

View File

@@ -1,6 +1,5 @@
import { import {
Hand, Hand,
ListOrdered,
LogOut, LogOut,
Maximize, Maximize,
MessageSquare, MessageSquare,
@@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
/** /**
* Опции захвата демонстрации экрана: `audio: true` — звук * Опции захвата демонстрации экрана: `audio: true` — звук
@@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps {
handQueue: HandQueueEntry[] handQueue: HandQueueEntry[]
onRaiseHand: () => void onRaiseHand: () => void
onLowerHand: () => void onLowerHand: () => void
handQueueOpen: boolean /** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onToggleHandQueue: () => void onLowerHandById: (identity: string) => void
} }
/** /**
@@ -104,8 +103,7 @@ export function RoomToolbar({
handQueue, handQueue,
onRaiseHand, onRaiseHand,
onLowerHand, onLowerHand,
handQueueOpen, onLowerHandById,
onToggleHandQueue,
layoutMode, layoutMode,
onLayoutModeChange, onLayoutModeChange,
hideOthers, hideOthers,
@@ -113,7 +111,6 @@ export function RoomToolbar({
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
const mic = useTrackToggle({ source: Track.Source.Microphone }) const mic = useTrackToggle({ source: Track.Source.Microphone })
@@ -197,23 +194,7 @@ export function RoomToolbar({
<span className="label">Рука</span> <span className="label">Рука</span>
</button> </button>
{isOrganizer && ( <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
<button
type="button"
className={`tb-btn${handQueueOpen ? ' is-panel-open' : ''}`}
aria-pressed={handQueueOpen}
aria-label={handQueueOpen ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={onToggleHandQueue}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
)}
{!isCompact && ( {!isCompact && (
<StageViewMenu <StageViewMenu
@@ -266,7 +247,14 @@ export function RoomToolbar({
<span className="icon-shell"> <span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" /> <PictureInPicture2 className="lucide" aria-hidden="true" />
</span> </span>
<span className="label">Мини-окно</span> {/* На узком экране («Мини-окно» иначе переносится на 2 строки и
кнопка становится выше соседних, см. .label-full/.label-short
в room.css) — только «Мини». Текст, не структура: aria-label
выше уже несёт полный смысл независимо от видимой подписи. */}
<span className="label">
<span className="label-full">Мини-окно</span>
<span className="label-short">Мини</span>
</span>
</button> </button>
)} )}

View File

@@ -16,7 +16,6 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage' import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar' import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel' import { ChatPanel } from '@/components/room/ChatPanel'
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher' import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice' import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
@@ -147,11 +146,6 @@ export function RoomPage() {
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте). // изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
const [handQueueOpen, setHandQueueOpen] = useState(false)
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()` // Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`). // на каждой плитке сцены (см. `RoomParticipantTile`).
const raisedHandIdentities = useMemo( const raisedHandIdentities = useMemo(
@@ -304,13 +298,6 @@ export function RoomPage() {
onClose={() => setChatOpen(false)} onClose={() => setChatOpen(false)}
/> />
)} )}
{handQueueOpen && (
<HandQueuePanel
queue={chat.handQueue}
onLower={(identity) => chat.lowerHand(identity)}
onClose={() => setHandQueueOpen(false)}
/>
)}
</div> </div>
<RoomToolbar <RoomToolbar
chatVisible={chatVisible} chatVisible={chatVisible}
@@ -327,8 +314,7 @@ export function RoomPage() {
handQueue={chat.handQueue} handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand} onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()} onLowerHand={() => chat.lowerHand()}
handQueueOpen={handQueueOpen} onLowerHandById={(identity) => chat.lowerHand(identity)}
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
layoutMode={layoutMode} layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange} onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers} hideOthers={hideOthers}

View File

@@ -277,32 +277,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; } .stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */ /* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
/*
* Кнопки тулбара плавно уменьшаются (иконка/отступы/шрифт/зазор) на всём
* диапазоне 1200px → 600px — до этого тулбар стал шире, чем при исходном
* проектировании (задачи B1/B2 добавили «Рука»/«Очередь», раньше помещались
* без сжатия 8 кнопок, теперь до 11 — без этого блока получался
* горизонтальный оверфлоу вплоть до самого мобильного брейкпоинта, кнопки
* вылезали за края тулбара).
*
* Обычный `clamp(min, Nvw, max)` тут не подходит: подобранный `N`
* дотягивается до `max` уже на довольно узких экранах (например,
* `3vw` = 24px ровно на 800px viewport) и дальше просто стоит на потолке —
* получается не плавное сжатие в нужном диапазоне, а резкий скачок сильно
* раньше нужной ширины (поймали именно так на первой версии этого блока).
* Вместо этого — явная линейная интерполяция между двумя точками
* (600px→минимум, 1200px→максимум): `calc(MIN + (MAX-MIN) * (100vw - 600px)
* / 600)`, снаружи в `clamp()` только чтобы намертво остановиться на
* границах диапазона. Нижние границы — те же значения, что жёстко
* выставляет мобильный медиа-запрос ниже (`max-width: 600px`), поэтому
* переход в него на 600px визуально бесшовный.
*/
.room-toolbar { .room-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-2); gap: clamp(2px, calc(2px + (100vw - 600px) * 6 / 600), var(--space-2));
background: var(--color-room-surface); background: var(--color-room-surface);
border-top: 1px solid var(--color-room-tile-border); border-top: 1px solid var(--color-room-tile-border);
padding: var(--space-3) var(--space-6); padding: var(--space-3) clamp(8px, calc(8px + (100vw - 600px) * 16 / 600), var(--space-6));
flex-shrink: 0; flex-shrink: 0;
} }
.tb-btn { .tb-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
background: transparent; background: transparent;
border: none; border: none;
padding: 8px 18px; padding:
clamp(6px, calc(6px + (100vw - 600px) * 2 / 600), 8px)
clamp(6px, calc(6px + (100vw - 600px) * 12 / 600), 18px);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-room-text-primary); color: var(--color-room-text-primary);
min-width: 76px; min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
cursor: pointer; cursor: pointer;
} }
.tb-btn .icon-shell { .tb-btn .icon-shell {
width: 48px; width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
height: 48px; height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
background: var(--color-room-tile); background: var(--color-room-tile);
color: var(--color-room-text-primary); color: var(--color-room-text-primary);
} }
.tb-btn span.label { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); font-weight: 500; } .tb-btn span.label {
font: var(--text-caption);
font-size: clamp(11px, calc(11px + (100vw - 600px) * 1 / 600), 12px);
text-transform: none;
letter-spacing: normal;
color: var(--color-room-text-secondary);
font-weight: 500;
}
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); } .tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
/* Короткая подпись мини-окна (см. RoomToolbar.tsx) — «Мини-окно» на узком
экране переносится на 2 строки и делает эту кнопку выше соседних; ниже
порога, где начинается перенос, прячем длинный вариант и показываем
короткий «Мини» — кнопка остаётся однострочной и той же высоты, что и
остальные. */
.tb-btn .label-short { display: none; }
@media (max-width: 1200px) {
.tb-btn .label-full { display: none; }
.tb-btn .label-short { display: inline; }
}
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; } .tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; } .tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
@@ -526,8 +566,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.chat-input-row button:disabled { opacity: 0.5; cursor: default; } .chat-input-row button:disabled { opacity: 0.5; cursor: default; }
/* Триггер и опции эмодзи-поповера лежат внутри `.chat-input-row` (форма
отправки) — тот же контейнер, что у круглой зелёной кнопки «Отправить»
(`.chat-input-row button`, специфичность 0,1,1). Голого класса
(`.chat-emoji-trigger`/`.chat-emoji-option`, 0,1,0) для победы над ней не
хватает — ЛЮБАЯ кнопка внутри формы (включая кнопки в самом поповере,
он тоже в этом поддереве) красилась в зелёный независимо от порядка
правил в файле. Каждый селектор ниже уточнён родительским классом ровно
затем, чтобы обойти именно эту гонку специфичности. */
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; } .chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
.chat-emoji-trigger { .chat-emoji-wrap .chat-emoji-trigger {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 50%; border-radius: 50%;
@@ -539,35 +587,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); } .chat-emoji-wrap .chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); } .chat-emoji-wrap .chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; } .chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
неполной последней строки. */
.chat-emoji-popover { .chat-emoji-popover {
position: absolute; position: absolute;
bottom: calc(100% + var(--space-2)); bottom: calc(100% + var(--space-2));
left: 0; left: 0;
z-index: 50; z-index: 50;
width: 224px; width: 220px;
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 2px; gap: 4px;
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--color-room-tile-border); border: 1px solid var(--color-room-tile-border);
background: var(--color-room-surface-raised); background: var(--color-room-surface-raised);
box-shadow: var(--shadow-room-panel); box-shadow: var(--shadow-room-panel);
} }
.chat-emoji-option { .chat-emoji-popover .chat-emoji-option {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: none; background: none;
border: none; border: none;
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
padding: 6px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
} }
.chat-emoji-option:hover { background: var(--color-room-tile); } .chat-emoji-popover .chat-emoji-option:hover { background: var(--color-room-tile); }
@media (max-width: 900px) { @media (max-width: 900px) {
.chat-panel { .chat-panel {
@@ -580,16 +633,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
} }
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ---------- /* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список * Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
* — свой. */ * `.chat-panel`: очередь — короткий список, а не история переписки,
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
.hand-queue-menu { width: 300px; padding: var(--space-3); }
.hand-queue-list { .hand-queue-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: var(--space-3); padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
/* Высота растёт вместе со списком (при 12 записях поповер компактный), но
не безгранично — после ~10 строк упирается в потолок и скроллится
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
max-height: 460px;
overflow-y: auto; overflow-y: auto;
} }
.hand-queue-item { .hand-queue-item {