10 Commits

Author SHA1 Message Date
cfb3a941ab release: версия 0.0.6
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 01:34:03 +03:00
f3556d7d89 fix(calendar): убрать «г.» из подписей дней в сетке
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.

Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).

Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
2026-07-27 01:32:02 +03:00
b6f57384b0 feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует
авторизации, поэтому работает и на экранах входа; поля `version` может не
быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и
ссылкой на профиль автора.

Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои
конференции), `AuthLayout` (вход, регистрация, подтверждение email),
`join-shell` и `lobby-shell`. В комнате конференции футера нет — там
полноэкранное видео.

Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не
осталось. В брендовой панели экранов auth строка «© 2026 VidConf ·
self-hosted» ужата до «self-hosted»: год там был захардкожен, а два
копирайта на одном экране — дубль.
2026-07-27 01:31:52 +03:00
5daff3b7af feat(room): закрепление участника и слежение основного окна за говорящим
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами:

- `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус
  вопреки говорящим, но уступает любой активной демонстрации экрана; как
  только демонстрация закончилась, фокус возвращается именно на
  закреплённого (правило стоит выше удержания предыдущего фокуса).
  Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого
  из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной
  булавкой; на тач-устройствах булавка видна без наведения.
- `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему
  (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий.

Основное окно теперь следует за говорящим (`followSpeaker`) поверх
`useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с
удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не
уводят. Среди одновременно говорящих предпочитается тот, у кого включена
камера (`cameraKeysWithVideo`).

`stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и
самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
2026-07-27 01:31:42 +03:00
28b87a8c95 release: версия 0.0.5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 00:56:37 +03:00
b35209a775 feat(admin): контактный адрес и тестовое письмо в настройках админки
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить
тестовое письмо» с выводом результата — рядом, по образцу соседних
настроек AdminSettingsTab.
2026-07-27 00:35:59 +03:00
84b06f78f1 test(email): контактный адрес, Reply-To и тестовая отправка письма
Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
2026-07-27 00:35:53 +03:00
74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00
09a3c6c806 release: версия 0.0.4
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-26 23:53:44 +03:00
86f1facf61 chore: не коммитить локальные конфиги сессий (.claude/) 2026-07-26 23:52:31 +03:00
36 changed files with 1278 additions and 111 deletions

View File

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

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ backend/media/
# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий # Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий
.forcc/ .forcc/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут.
.claude/

View File

@@ -3,6 +3,81 @@
Формат основан на [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.6] — 2026-07-27
Управление фокусом основного окна конференции: участника можно закрепить, а
без закрепления окно само следует за говорящим. Плюс общий футер на всех
страницах сервиса.
### Добавлено
- Закрепление участника в основном окне — кнопка-булавка на плитке.
Закреплённый участник держит фокус, даже когда говорят другие; повторное
нажатие снимает закрепление, как и выход закреплённого из комнаты.
Закреплённая плитка помечена рамкой и подсвеченной булавкой; на
тач-устройствах булавка видна без наведения.
- Общий футер на всех страницах, кроме комнаты конференции: версия инстанса
и копирайт со ссылкой на автора.
### Изменения
- Основное окно конференции следует за активным говорящим, а не удерживает
выбранного один раз участника. Фокус не переключается на короткие реплики
(удержание 1.2 с) и не уводится с идущей демонстрации экрана. Среди
нескольких одновременно говорящих крупно показывается тот, у кого включена
камера. Мини-плеер (Document PiP) работает как раньше.
- Демонстрация экрана по-прежнему главнее всего: она перебивает и
закрепление, а после её завершения фокус возвращается на закреплённого
участника.
- Футер админки заменён общим компонентом; в брендовой панели экранов входа
строка копирайта с захардкоженным годом убрана — год теперь берётся
текущий.
### Исправлено
- Календарь: «г.» после года убрана и из подписей дней в самой сетке (видны
как всплывающая подсказка и в accessibility-дереве) — в недельном и
месячном виде. В 0.0.4 это было исправлено только для подписи периода.
## [0.0.5] — 2026-07-27
Контактный адрес инстанса: настраиваемый адрес для ответов на письма
(уходят от `no-reply@`) и кнопка проверки почтовой конфигурации из
админки.
### Добавлено
- Настройка «Контактный адрес» в админке (вкладка «Настройки») — email
подставляется в заголовок `Reply-To` писем подтверждения регистрации,
приглашений на конференции и саммари; при выключенной настройке
`Reply-To` не проставляется, поведение как раньше.
- Кнопка «Отправить тестовое письмо» — синхронная проверка почтовой
конфигурации без регистрации фиктивного пользователя. Получатель по
умолчанию — email текущего администратора, можно указать другой.
Результат виден сразу: успех либо текст ошибки транспорта (сервер
недоступен/получатель отклонён), без логина и пароля SMTP в ответе.
## [0.0.4] — 2026-07-26
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
модальные окна» — карточки событий разгружены, подробности вынесены во
всплывающее окно.
### Изменения
- Форма создания и редактирования конференции больше не занимает постоянную
колонку справа, а открывается в модальном окне — по кнопке «Новая
конференция», по карандашику на карточке события или из информационного
окна. Добавлен общий хук `useModalDismiss` (закрытие по Escape и возврат
фокуса на элемент, из которого окно открыли).
- Блок календаря занимает всю ширину страницы.
- Карточка события в сетке содержит только название (обрезается многоточием и
больше не вылезает за границы ячейки) и — у организатора — кнопку
редактирования. Время, номер и статус переехали в информационное окно.
- Клик по карточке события всегда открывает информационное окно, в том числе
для своих конференций: редактирование стало отдельным действием.
- Информационное окно вхождения дополнено статус-бейджем и кнопкой
«Редактировать» для организатора.
### Исправления
- Подпись периода в недельном и месячном виде больше не содержит «г.»
(форматирование через `Intl.DateTimeFormat` вместо локали FullCalendar).
## [0.0.3] — 2026-07-26 ## [0.0.3] — 2026-07-26
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
@@ -87,6 +162,9 @@
Первоначальная версия VidConf. Первоначальная версия VidConf.
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3 [0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2 [0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2
[0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1 [0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1

View File

@@ -1 +1 @@
0.0.3 0.0.6

View File

@@ -45,6 +45,8 @@ from schemas.admin import (
TeamListOut, TeamListOut,
TeamOut, TeamOut,
TeamUpdateIn, TeamUpdateIn,
TestEmailIn,
TestEmailOut,
) )
from schemas.conferences import ConferenceUpdateIn from schemas.conferences import ConferenceUpdateIn
from services.ai_levels import detect_ai_levels from services.ai_levels import detect_ai_levels
@@ -56,9 +58,11 @@ from services.conferences import (
InvalidConferenceStateError, InvalidConferenceStateError,
NotConferenceOwnerError, NotConferenceOwnerError,
) )
from services.email import EmailSendError, create_email_backend
from services.instance_settings import ( from services.instance_settings import (
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
SettingsUpdateIn, SettingsUpdateIn,
@@ -391,12 +395,59 @@ async def update_settings(
service = InstanceSettingsService(session) service = InstanceSettingsService(session)
try: try:
cfg = await service.update(data) cfg = await service.update(data)
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc: except (
InvalidAiLevelError,
InvalidTimezoneError,
InvalidEmailDomainError,
InvalidContactEmailError,
) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served) queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served) return _to_settings_out(cfg, transcription_queue_served=queue_served)
@router.post("/settings/test-email", response_model=TestEmailOut)
async def send_test_email(
data: TestEmailIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TestEmailOut:
"""Отправить тестовое письмо синхронно — проверка почтовой конфигурации без
регистрации фиктивного пользователя.
Получатель по умолчанию — email текущего администратора. Отправка идёт
напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу.
Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт,
и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`).
"""
app_settings = get_app_settings()
recipient = data.to or admin.email
cfg = await InstanceSettingsService(session).get()
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
is_smtp = app_settings.email_backend == "smtp"
smtp_host = app_settings.smtp_host if is_smtp else None
smtp_port = app_settings.smtp_port if is_smtp else None
backend = create_email_backend(app_settings)
try:
await backend.send(
to=recipient,
subject="Тестовое письмо VidConf",
body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.",
reply_to=reply_to,
)
except EmailSendError as exc:
return TestEmailOut(
success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port
)
return TestEmailOut(
success=True,
message=f"Письмо успешно отправлено на {recipient}",
smtp_host=smtp_host,
smtp_port=smtp_port,
)
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut: def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut( return SettingsOut(
@@ -410,6 +461,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
registration_team_choice=cfg.registration_team_choice, registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled, registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain, registration_email_domain=cfg.registration_email_domain,
contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email,
) )

View File

@@ -82,3 +82,8 @@ class InstanceConfig(BaseModel):
# `services/instance_settings.py`. # `services/instance_settings.py`.
registration_email_domain_enabled: bool = False registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None registration_email_domain: str | None = None
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
contact_email_enabled: bool = False
contact_email: str | None = None

View File

@@ -156,3 +156,28 @@ class SettingsOut(BaseModel):
registration_team_choice: bool registration_team_choice: bool
registration_email_domain_enabled: bool registration_email_domain_enabled: bool
registration_email_domain: str | None = None registration_email_domain: str | None = None
contact_email_enabled: bool
contact_email: str | None = None
class TestEmailIn(BaseModel):
"""Тело запроса тестовой отправки (`POST /admin/settings/test-email`).
`to` не задан — получатель по умолчанию: email текущего администратора
(см. `api/admin.py::send_test_email`).
"""
to: EmailStr | None = None
class TestEmailOut(BaseModel):
"""Результат тестовой отправки — понятный админу успех/ошибка транспорта.
`smtp_host`/`smtp_port` — только хост и порт (без логина/пароля, они не
покидают `.env`), заполняются лишь при `EMAIL_BACKEND=smtp`.
"""
success: bool
message: str
smtp_host: str | None = None
smtp_port: int | None = None

View File

@@ -134,7 +134,8 @@ class AuthService:
password_hash=hash_password(password), password_hash=hash_password(password),
team_id=team_id, team_id=team_id,
) )
await self._issue_verification_email(user) reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to)
await self._session.commit() await self._session.commit()
return user return user
@@ -216,7 +217,7 @@ class AuthService:
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds) await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token) return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User) -> None: async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta( expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours hours=self._settings.email_verification_ttl_hours
@@ -231,6 +232,7 @@ class AuthService:
to=user.email, to=user.email,
subject="Подтверждение регистрации VidConf", subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}", body=f"Для подтверждения email перейдите по ссылке: {link}",
reply_to=reply_to,
) )

View File

@@ -56,8 +56,14 @@ class EmailBackend(Protocol):
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).""" """Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).
`reply_to` — необязательный контактный адрес инстанса (см.
`services/instance_settings.py::InstanceConfig.contact_email`), проставляется
заголовком `Reply-To`, если задан.
"""
... ...
@@ -72,13 +78,15 @@ class ConsoleEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого).""" """Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет" attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info( logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s", "EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
to, to,
subject, subject,
reply_to or "нет",
attachment_names, attachment_names,
body, body,
) )
@@ -116,6 +124,7 @@ class SmtpEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`.""" """Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message( message = _build_message(
@@ -125,6 +134,7 @@ class SmtpEmailBackend:
body=body, body=body,
html_body=html_body, html_body=html_body,
attachments=attachments, attachments=attachments,
reply_to=reply_to,
) )
try: try:
await aiosmtplib.send( await aiosmtplib.send(
@@ -165,12 +175,15 @@ def _build_message(
body: str, body: str,
html_body: str | None, html_body: str | None,
attachments: Sequence[EmailAttachment], attachments: Sequence[EmailAttachment],
reply_to: str | None = None,
) -> EmailMessage: ) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения.""" """Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage() message = EmailMessage()
message["From"] = sender message["From"] = sender
message["To"] = to message["To"] = to
message["Subject"] = subject message["Subject"] = subject
if reply_to:
message["Reply-To"] = reply_to
message.set_content(body) message.set_content(body)
if html_body is not None: if html_body is not None:
message.add_alternative(html_body, subtype="html") message.add_alternative(html_body, subtype="html")

View File

@@ -2,8 +2,8 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`) — новая настройка `registration_team_choice`, `registration_email_domain`, `contact_email`) —
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором (например, при рестарте backend) не перетирает уже сделанные администратором
@@ -17,7 +17,8 @@ from pathlib import Path
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel from pydantic import BaseModel, EmailStr, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,6 +46,7 @@ _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT, _KEY_CHAT,
@@ -62,6 +64,7 @@ _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False} _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None} _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка, # Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -70,6 +73,11 @@ _EMAIL_DOMAIN_PATTERN = re.compile(
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
) )
# Валидация формата контактного email — переиспользует тот же валидатор,
# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без
# отдельного регэкспа под адрес целиком.
_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr)
class SettingsUpdateIn(BaseModel): class SettingsUpdateIn(BaseModel):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч). """Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
@@ -88,6 +96,8 @@ class SettingsUpdateIn(BaseModel):
registration_team_choice: bool | None = None registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None registration_email_domain: str | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
class BootstrapOverrides(BaseModel): class BootstrapOverrides(BaseModel):
@@ -136,6 +146,7 @@ def build_bootstrap_defaults(
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
} }
if overrides is None: if overrides is None:
return defaults return defaults
@@ -172,6 +183,15 @@ class InvalidEmailDomainError(ValueError):
""" """
class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса.
Поднимается при попытке включить контактный адрес без email
(`enabled=true` и пустой/отсутствующий email) либо при email, не
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
"""
class InstanceSettingsService: class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`.""" """CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
@@ -268,6 +288,28 @@ class InstanceSettingsService:
cfg.registration_email_domain = domain cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain}) await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
if patch.contact_email_enabled is not None or patch.contact_email is not None:
contact_enabled = (
patch.contact_email_enabled
if patch.contact_email_enabled is not None
else cfg.contact_email_enabled
)
raw_contact_email = (
patch.contact_email if patch.contact_email is not None else cfg.contact_email
)
contact_email = (
_normalize_contact_email(raw_contact_email) if raw_contact_email else None
)
if contact_enabled and contact_email is None:
raise InvalidContactEmailError(
"нельзя включить контактный адрес без указания email"
)
cfg.contact_email_enabled = contact_enabled
cfg.contact_email = contact_email
await self._set(
_KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email}
)
if patch.transcription_enabled is not None: if patch.transcription_enabled is not None:
cfg.transcriber = cfg.transcriber.model_copy( cfg.transcriber = cfg.transcriber.model_copy(
update={"enabled": patch.transcription_enabled} update={"enabled": patch.transcription_enabled}
@@ -360,6 +402,16 @@ def _normalize_email_domain(domain: str) -> str:
return normalized return normalized
def _normalize_contact_email(email: str) -> str:
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
normalized = email.strip().lower()
try:
_CONTACT_EMAIL_ADAPTER.validate_python(normalized)
except PydanticValidationError as exc:
raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc
return normalized
def _build_config(rows: dict[str, Any]) -> InstanceConfig: def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей. """Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
@@ -387,4 +439,8 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
registration_email_domain=rows.get( registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE _KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"), ).get("domain"),
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
"enabled", False
),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
) )

View File

@@ -24,6 +24,7 @@ from models.team import Team
from models.user import User from models.user import User
from services import ai_levels from services import ai_levels
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
FUTURE = datetime.now(UTC) + timedelta(days=3) FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}), ("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None), ("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}), ("PUT", "/api/v1/admin/settings", {}),
("POST", "/api/v1/admin/settings/test-email", {}),
] ]
for method, path, body in requests: for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers) response = await client.request(method, path, json=body, headers=headers)
@@ -606,3 +608,153 @@ async def test_put_settings_unavailable_ai_level_returns_400(
headers=_auth_headers(admin), headers=_auth_headers(admin),
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_put_settings_contact_email_enable_and_persist(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["contact_email_enabled"] is True
assert body["contact_email"] == "contact@vidconf.ru"
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
async def test_put_settings_contact_email_invalid_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "not an email"},
headers=_auth_headers(admin),
)
assert response.status_code == 400
# --- Тестовое письмо ----------------------------------------------------------------
async def test_send_test_email_defaults_to_admin_email(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is True
assert sent["to"] == admin.email
async def test_send_test_email_to_explicit_recipient(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email",
json={"to": "other@example.com"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert sent["to"] == "other@example.com"
async def test_send_test_email_uses_contact_email_as_reply_to(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
headers=_auth_headers(admin),
)
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert sent["reply_to"] == "contact@vidconf.example"
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
get_settings.cache_clear()
class _FailingBackend:
async def send(self, **kwargs: object) -> None:
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is False
assert "сбой SMTP" in body["message"]
assert body["smtp_host"] == "smtp.example.com"
assert body["smtp_port"] == 2525
assert "secret-user" not in response.text
assert "super-secret-password" not in response.text
finally:
get_settings.cache_clear()

View File

@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
def __init__(self) -> None: def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = [] self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send( async def send(
self, self,
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
self.sent.append((to, subject, body)) self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
def _extract_verification_token(email_body: str) -> str: def _extract_verification_token(email_body: str) -> str:
@@ -432,3 +435,34 @@ async def test_register_any_domain_allowed_when_verification_disabled(
}, },
) )
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
async def test_register_sets_reply_to_when_contact_email_enabled(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] == "contact@vidconf.example"
async def test_register_no_reply_to_when_contact_email_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides, BootstrapOverrides,
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
SettingsUpdateIn, SettingsUpdateIn,
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email",
) )
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -132,6 +135,8 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.registration_team_choice is False assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled is False assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None assert cfg.registration_email_domain is None
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -384,6 +389,68 @@ async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
assert cfg.registration_email_domain == "acme.io" assert cfg.registration_email_domain == "acme.io"
async def test_contact_email_enable_without_email_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email_enabled=True))
cfg = await service.get()
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_contact_email_rejects_invalid_format(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email="not an email"))
cfg = await service.get()
assert cfg.contact_email is None
async def test_contact_email_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
)
assert cfg.contact_email_enabled is True
assert cfg.contact_email == "contact@vidconf.ru"
reloaded = await service.get()
assert reloaded.contact_email == "contact@vidconf.ru"
async def test_contact_email_can_be_disabled_keeping_stored_email(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
)
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
assert cfg.contact_email_enabled is False
assert cfg.contact_email == "contact@acme.io"
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer( async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text from sqlalchemy import text
from core.db import engine from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module from workers.tasks import invitations as invitations_module
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (), attachments: tuple[EmailAttachment, ...] = (),
reply_to: str | None = None,
) -> None: ) -> None:
if to in self._fail_for: if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable) raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
self.sent.append({"to": to, "subject": subject, "attachments": attachments}) self.sent.append(
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
)
class _Fixture: class _Fixture:
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
assert delivered == sent_to assert delivered == sent_to
def _cfg(
*, contact_email_enabled: bool = False, contact_email: str | None = None
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
async def test_invitations_set_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(
_FakeTask(),
fx.conference_id,
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
)
assert mock_backend.sent
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
async def test_invitations_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
assert mock_backend.sent
assert all(call["reply_to"] is None for call in mock_backend.sent)
async def test_explicit_emails_override_default_recipients( async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -60,6 +60,7 @@ class _FakeEmailBackend:
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None: def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = [] self.sent: list[str] = []
self.reply_to: list[str | None] = []
self._fail_for = fail_for or set() self._fail_for = fail_for or set()
self._retryable = retryable self._retryable = retryable
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Any = (), attachments: Any = (),
reply_to: str | None = None,
) -> None: ) -> None:
if to in self._fail_for: if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable) raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to) self.sent.append(to)
self.reply_to.append(reply_to)
class _Fixture: class _Fixture:
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
await conn.commit() await conn.commit()
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig: def _cfg(
*,
summary_recipients: str = "all",
contact_email_enabled: bool = False,
contact_email: str | None = None,
) -> InstanceConfig:
return InstanceConfig( return InstanceConfig(
transcriber=TranscriberConfig(), transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(), summarizer=SummarizerConfig(),
chat=ChatConfig(), chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients), summary_recipients=cast("Any", summary_recipients),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
) )
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
assert await _fetch_session_status(fx.session_id) == "notified" assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(),
fx.session_id,
plugins_config=_cfg(
summary_recipients="owner",
contact_email_enabled=True,
contact_email="contact@vidconf.example",
),
)
assert backend.reply_to == ["contact@vidconf.example"]
async def test_notify_session_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.reply_to == [None]
async def test_notify_session_conference_override_wins_over_instance_default( async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -7,6 +7,8 @@
""" """
import logging import logging
from email.message import EmailMessage
from typing import cast
import aiosmtplib import aiosmtplib
import pytest import pytest
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
def test_build_message_sets_reply_to_when_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
reply_to="contact@vidconf.example",
)
assert message["Reply-To"] == "contact@vidconf.example"
def test_build_message_omits_reply_to_when_not_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
)
assert message["Reply-To"] is None
def _backend() -> SmtpEmailBackend: def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend( return SmtpEmailBackend(
hostname="smtp.example.com", hostname="smtp.example.com",
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
"use_tls": False, "use_tls": False,
"timeout": 30, "timeout": 30,
} }
async def test_smtp_backend_send_passes_reply_to_into_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
captured["message"] = message
return {}, "OK"
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
backend = _backend()
await backend.send(
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
)
message = cast(EmailMessage, captured["message"])
assert message["Reply-To"] == "contact@vidconf.example"

View File

@@ -82,7 +82,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.3} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.6}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки — # config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно. # только backend/), поэтому plugins.yaml монтируется отдельно.
volumes: volumes:

View File

@@ -34,6 +34,10 @@ export interface SettingsOut {
registration_email_domain_enabled: boolean registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */ /** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | null registration_email_domain: string | null
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null
} }
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -48,6 +52,24 @@ export interface SettingsUpdateIn {
/** Включение без домена или невалидный домен — backend отвечает 400. */ /** Включение без домена или невалидный домен — backend отвечает 400. */
registration_email_domain_enabled?: boolean registration_email_domain_enabled?: boolean
registration_email_domain?: string | null registration_email_domain?: string | null
/** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean
contact_email?: string | null
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
export interface TestEmailIn {
/** Не задан — backend отправит на email текущего администратора. */
to?: string | null
}
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
export interface TestEmailOut {
success: boolean
message: string
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
smtp_host: string | null
smtp_port: number | null
} }
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */ /** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
@@ -238,6 +260,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload }) return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
} }
/**
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
*/
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
}
/** Список команд для админки — с пагинацией. */ /** Список команд для админки — с пагинацией. */
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> { export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
const qs = toQueryString({ limit: query.limit, offset: query.offset }) const qs = toQueryString({ limit: query.limit, offset: query.offset })

View File

@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin' import {
getAdminSettings,
sendTestEmail,
updateAdminSettings,
type AiLevel,
type SettingsOut,
type SettingsUpdateIn,
type TestEmailOut,
} from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences' import type { SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
function AdminSettingsForm({ data }: { data: SettingsOut }) { function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toast = useToast() const toast = useToast()
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
@@ -51,6 +61,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice) const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled) const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '') const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload), mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
@@ -67,6 +81,19 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
}, },
}) })
const testEmailMutation = useMutation({
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
onSuccess: (result) => setTestEmailResult(result),
onError: (err: unknown) => {
setTestEmailResult({
success: false,
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
smtp_host: null,
smtp_port: null,
})
},
})
function handleSave() { function handleSave() {
mutation.mutate({ mutation.mutate({
chat_enabled: chatEnabled, chat_enabled: chatEnabled,
@@ -77,6 +104,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
registration_team_choice: teamChoiceEnabled, registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled, registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null, registration_email_domain: emailDomain.trim() || null,
contact_email_enabled: contactEmailEnabled,
contact_email: contactEmail.trim() || null,
}) })
} }
@@ -268,6 +297,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </section>
<section className="settings-card">
<h2>Контактный адрес</h2>
<p className="desc">
Адрес для ответов на письма от инстанса (уходят от no-reply этот адрес подставляется
в заголовок «Reply-To»).
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Контактный адрес включён</strong>
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={contactEmailEnabled}
onChange={(e) => setContactEmailEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-contact-email">Email</label>
<input
id="settings-contact-email"
type="email"
placeholder="contact@vidconf.ru"
value={contactEmail}
disabled={!contactEmailEnabled}
onChange={(e) => setContactEmail(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
<div className="settings-card-body">
<div className="field">
<label htmlFor="settings-test-email-to">Получатель</label>
<input
id="settings-test-email-to"
type="email"
placeholder={user?.email ?? 'you@example.com'}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={testEmailMutation.isPending}
onClick={() => testEmailMutation.mutate()}
>
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
</button>
{testEmailResult && (
<p
className="field-hint"
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
>
{testEmailResult.message}
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
</p>
)}
</div>
</section>
<div className="settings-actions"> <div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}> <button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'} {mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark' import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle' import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/auth.css' import '@/styles/auth.css'
@@ -41,11 +42,18 @@ export function AuthLayout({ children }: { children: ReactNode }) {
</div> </div>
</div> </div>
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}> <p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
© 2026 VidConf · self-hosted self-hosted
</p> </p>
</aside> </aside>
<main className="form-panel">{children}</main> {/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
остальных страницах). Из брендовой панели строку «© 2026 VidConf ·
self-hosted» при этом ужали до «self-hosted»: год там был
захардкожен, а два копирайта на одном экране — дубль. */}
<main className="form-panel">
{children}
<AppFooter />
</main>
</div> </div>
) )
} }

View File

@@ -98,6 +98,57 @@ function renderEventContent(arg: EventContentArg) {
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */ /** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Подписи дней в самой сетке, из которых надо убрать «г.» (см.
* `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный —
* подпись колонки дня.
*/
const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion'
/** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */
function stripYearSuffixAttrs(el: Element) {
for (const attr of ['aria-label', 'title']) {
const value = el.getAttribute(attr)
if (!value) continue
const cleaned = value.replace(YEAR_SUFFIX_RE, '')
if (cleaned !== value) el.setAttribute(attr, cleaned)
}
}
/**
* Убирает «г.» из служебных подписей дней внутри сетки FullCalendar.
*
* Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`),
* дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в
* accessibility-дереве и как всплывающая подсказка. Формат этой подписи
* библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core)
* форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт
* результат в `aria-label` (в `title` — когда включены navLinks), а с локалью
* `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается,
* поэтому правим уже отрендеренный атрибут.
*
* Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`:
* при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ
* уже смонтированные ячейки — `didMount` для них повторно не зовётся, а
* подпись внутри перерисовывается заново, и «г.» возвращается (поймали на
* живой проверке: после переключения вида ровно строка переиспользованных
* ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку.
* Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он
* реально меняется, поэтому вызванная нашей же правкой итерация ничего не
* пишет и цепочка обрывается.
*/
function useStripYearSuffix(rootRef: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const root = rootRef.current
if (!root) return
const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs)
clean()
const observer = new MutationObserver(clean)
observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] })
return () => observer.disconnect()
}, [rootRef])
}
/** /**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat` * Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не * (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
@@ -152,6 +203,9 @@ export function ConferenceCalendar({
}: ConferenceCalendarProps) { }: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null) const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null) const titleRef = useRef<HTMLSpanElement | null>(null)
const gridRef = useRef<HTMLDivElement | null>(null)
useStripYearSuffix(gridRef)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем // См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent. // переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
@@ -269,7 +323,7 @@ export function ConferenceCalendar({
</div> </div>
</div> </div>
<div className="vc-calendar" aria-busy={isLoading}> <div className="vc-calendar" aria-busy={isLoading} ref={gridRef}>
<FullCalendar <FullCalendar
ref={calendarRef} ref={calendarRef}
plugins={CALENDAR_PLUGINS} plugins={CALENDAR_PLUGINS}

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { getHealth } from '@/api/health'
import '@/styles/footer.css'
/**
* Общий футер приложения: версия инстанса и копирайт со ссылкой на автора.
*
* Ставится на всех страницах, КРОМЕ комнаты конференции (`RoomPage`) — там
* полноэкранное видео и тулбар, футеру места нет. Раскладок в приложении
* несколько (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`), поэтому
* компонент рендерит только собственный `<footer>`, а привязку к конкретной
* раскладке делает CSS (см. `styles/footer.css`).
*
* Версия — из публичного `GET /api/health` (авторизации не требует, годится и
* для экранов входа). Поле `version` опционально: если backend его не отдал,
* версию просто не показываем (см. `HealthOut.version`). Ключ запроса тот же
* `['health']`, что и у админки, — react-query отдаст общий закэшированный
* результат, лишнего запроса не будет.
*
* Год — текущий, из `Date` (не захардкожен): страница живёт в браузере
* пользователя и не должна устаревать 1 января.
*/
export function AppFooter() {
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<footer className="app-footer">
VidConf{health?.version ? ` · v${health.version}` : ''} · © {new Date().getFullYear()} · by{' '}
<a href="https://github.com/MaxRonzhin" target="_blank" rel="noopener">
MaxRonzhin
</a>
</footer>
)
}

View File

@@ -1,4 +1,4 @@
import { ScreenShare } from 'lucide-react' import { Pin, PinOff, ScreenShare } from 'lucide-react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { import {
AudioTrack, AudioTrack,
@@ -19,6 +19,7 @@ import {
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-react' } from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar' import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */ /** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata { interface ParticipantMetadata {
@@ -43,7 +44,7 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`, * разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии). * версия 2.9.23 — источник этой копии).
*/ */
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) { function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) {
const trackReference = useEnsureTrackRef() const trackReference = useEnsureTrackRef()
const isEncrypted = useIsEncrypted(trackReference.participant) const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -60,6 +61,12 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
const showSharingChip = Boolean( const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal, onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
) )
// Кнопка закрепления — только там, где сцена умеет закрепление (основное
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
return ( return (
<> <>
@@ -106,6 +113,25 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
<ConnectionQualityIndicator className="lk-participant-metadata-item" /> <ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div> </div>
<FocusToggle trackRef={trackReference} /> <FocusToggle trackRef={trackReference} />
{onTogglePin && (
<button
type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'}
aria-label={
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке».
e.stopPropagation()
onTogglePin(tileKey)
}}
>
{isPinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
</button>
)}
{showSharingChip && ( {showSharingChip && (
<div className="stage-sharing-chip"> <div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" /> <ScreenShare className="lucide" aria-hidden="true" />
@@ -119,10 +145,8 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
) )
} }
interface RoomParticipantTileProps { /** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
trackRef?: TrackReferenceOrPlaceholder interface TileControlsProps {
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
/** /**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной * Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх * демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
@@ -131,6 +155,23 @@ interface RoomParticipantTileProps {
* в этом приложении не появляется (см. `RoomStage.tsx`). * в этом приложении не появляется (см. `RoomStage.tsx`).
*/ */
onStopSharing?: () => void onStopSharing?: () => void
/**
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
*/
pinnedKey?: string | null
/**
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
}
interface RoomParticipantTileProps extends TileControlsProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
} }
/** /**
@@ -142,15 +183,20 @@ interface RoomParticipantTileProps {
* собственного токена), и для удалённых. * собственного токена), и для удалённых.
* *
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека) * Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext` * сознательно опущена — приложение нигде не создаёт `LayoutContext`, поэтому
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была * в оригинале эта ветка и так была мёртвым кодом без провайдера контекста (по
* мёртвым кодом без провайдера контекста. * той же причине ничего не рисует и штатный `FocusToggle` ниже). Своё
* закрепление участника (задача 3.1) сделано мимо `LayoutContext`: состояние —
* в `RoomStage`, кнопка — `.room-pin-toggle` здесь, выбор фокуса —
* `pickStageFocus`.
*/ */
export function RoomParticipantTile({ export function RoomParticipantTile({
trackRef, trackRef,
disableSpeakingIndicator, disableSpeakingIndicator,
onParticipantClick, onParticipantClick,
onStopSharing, onStopSharing,
pinnedKey,
onTogglePin,
}: RoomParticipantTileProps) { }: RoomParticipantTileProps) {
return ( return (
<ParticipantTile <ParticipantTile
@@ -158,7 +204,7 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator} disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick} onParticipantClick={onParticipantClick}
> >
<TileBody onStopSharing={onStopSharing} /> <TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
</ParticipantTile> </ParticipantTile>
) )
} }

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Track } from 'livekit-client' import { Track, type Participant } from 'livekit-client'
import { import {
CarouselLayout, CarouselLayout,
FocusLayoutContainer, FocusLayoutContainer,
@@ -12,7 +12,7 @@ import {
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-react' } from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile' import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { pickStageFocus } from '@/components/room/stageFocus' import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
/** /**
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на * Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
@@ -39,9 +39,45 @@ const STAGE_TRACK_SOURCES = [
{ source: Track.Source.ScreenShare, withPlaceholder: false }, { source: Track.Source.ScreenShare, withPlaceholder: false },
] ]
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */ /**
function stageTrackKey(t: TrackReferenceOrPlaceholder): string { * Удержание фокуса основного окна при смене говорящего, мс.
return `${t.participant.identity}:${t.source}` *
* Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
* реплику длиной в полсекунды он честно отдаёт как смену состава.
*
* Значение подобрано от периода самого события: LiveKit пересчитывает активных
* спикеров примерно раз в 0.5 с, то есть короткая реплика — это 12 обновления.
* 1.2 с ≈ 23 обновления: блик до 1.2 с не проходит вовсе (значение успевает
* вернуться обратно, таймер перезапускается), а осмысленная фраза переключает
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика.
*/
const SPEAKER_HOLD_MS = 1200
/**
* Возвращает состав говорящих, «успокоенный» удержанием: новое значение
* применяется, только если оно продержалось `holdMs` без изменений. Короткая
* реплика меняет состав и возвращает его обратно раньше таймера — тогда
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим).
*
* `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там
* фокус обязан следовать за говорящим мгновенно, поведение не менялось).
*/
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers)
useEffect(() => {
if (holdMs <= 0 || speakers === steady) return
const timer = setTimeout(() => setSteady(speakers), holdMs)
return () => clearTimeout(timer)
}, [speakers, steady, holdMs])
return holdMs <= 0 ? speakers : steady
} }
/** /**
@@ -75,23 +111,27 @@ function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
* *
* Проп `variant="pip"` — для рендера * Проп `variant="pip"` — для рендера
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме * ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида * показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида.
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у *
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер * Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
* (`variant="full"`, дефолт) не меняется вовсе. * `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
* PiP переключается мгновенно и всегда показывает говорящего, а основное окно
* ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
*/ */
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) { export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
const room = useRoomContext() const room = useRoomContext()
const tracks = useTracks(STAGE_TRACK_SOURCES, { const tracks = useTracks(STAGE_TRACK_SOURCES, {
onlySubscribed: false, onlySubscribed: false,
}) })
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже // Активные спикеры уже отсортированы SDK по громкости
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по // (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене // событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от // дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of // Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers`
// Hooks) — для `variant="full"` его результат просто не используется. // и `SPEAKER_HOLD_MS`), PiP берёт значение как есть.
const speakingParticipants = useSpeakingParticipants() const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera) const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare) const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -121,47 +161,66 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
// новый массив только когда реально что-то изменилось (см. комментарий у // новый массив только когда реально что-то изменилось (см. комментарий у
// `STAGE_TRACK_SOURCES` про стабильность ссылки). // `STAGE_TRACK_SOURCES` про стабильность ссылки).
// //
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой // Пересчёт триггерится ещё и сменой `speakingParticipants` (тоже сравнение
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый // по ссылке — хук отдаёт новый массив только при реальном изменении состава/
// массив только при реальном изменении состава/порядка говорящих), и // порядка говорящих), и сменой закрепления (`pinnedKey`) — оба входа
// передаётся `followSpeaker: true` — фокус живо переключается на нового // `pickStageFocus` меняют результат без изменения самих треков.
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
// поведение байт-в-байт то же, что было до этой правки.
const [prevTracks, setPrevTracks] = useState(tracks) const [prevTracks, setPrevTracks] = useState(tracks)
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants) const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
const [focusKey, setFocusKey] = useState<string | null>(null) const [focusKey, setFocusKey] = useState<string | null>(null)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Только для основного окна — в PiP плитка одна и закреплять нечего.
const [pinnedKey, setPinnedKey] = useState<string | null>(null)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey)
const screenShareKeys = screenShareTracks.map(stageTrackKey)
// Закреплённый участник вышел из комнаты (его ключа нет ни среди камер — а
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе.
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks const tracksChanged = tracks !== prevTracks
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey
if (tracksChanged || speakingChanged) { if (pinnedKey !== null && !pinnedAlive) {
setPinnedKey(null)
}
if (tracksChanged || speakingChanged || pinnedChanged) {
const prevKeys = prevTracks.map(stageTrackKey) const prevKeys = prevTracks.map(stageTrackKey)
if (tracksChanged) setPrevTracks(tracks) if (tracksChanged) setPrevTracks(tracks)
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants) if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно: if (pinnedChanged) setPrevPinnedKey(pinnedKey)
// здесь строго тот же расчёт, что был в основном окне ДО этой правки // Говорящие — упорядоченные по громкости камера-ключи: нужен именно
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый // порядок (взять самого громкого) и именно throttled-источник SDK
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для // (`useSpeakingParticipants`, в основном окне ещё и с удержанием), чтобы
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно // followSpeaker не дёргался на каждый чих. Сырой `participant.isSpeaking`,
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK, // на котором основное окно жило до 0.0.6, дребезжит и для слежения за
// чтобы followSpeaker не дёргался на каждый чих). // спикером не годится.
const speakingCameraKeys = const speakingCameraKeys = speakingParticipants
variant === 'pip' .map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
? speakingParticipants .filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity)) .map(stageTrackKey)
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
const result = pickStageFocus({ const result = pickStageFocus({
cameraKeys: cameraTracks.map(stageTrackKey), cameraKeys,
screenShareKeys: screenShareTracks.map(stageTrackKey), screenShareKeys,
speakingCameraKeys, speakingCameraKeys,
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
cameraKeysWithVideo:
variant === 'pip'
? []
: cameraTracks.filter((t) => isTrackReference(t) && !t.publication.isMuted).map(stageTrackKey),
prevKeys, prevKeys,
prevFocusKey: focusKey, prevFocusKey: focusKey,
followSpeaker: variant === 'pip', pinnedKey: pinnedAlive ? pinnedKey : null,
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся followSpeaker: true,
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем). holdScreenShare: variant !== 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null, localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
}) })
if (result.focusKey !== focusKey) { if (result.focusKey !== focusKey) {
@@ -192,6 +251,15 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
void room.localParticipant.setScreenShareEnabled(false) void room.localParticipant.setScreenShareEnabled(false)
} }
/**
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*/
function handleTogglePin(key: string) {
setPinnedKey((prev) => (prev === key ? null : key))
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`, // грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus` // `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
@@ -209,17 +277,24 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
<section className="stage"> <section className="stage">
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? ( {!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
<GridLayout tracks={tracks} className="stage-tiles"> <GridLayout tracks={tracks} className="stage-tiles">
<RoomParticipantTile /> <RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
</GridLayout> </GridLayout>
) : ( ) : (
<FocusLayoutContainer className="stage-tiles"> <FocusLayoutContainer className="stage-tiles">
<CarouselLayout tracks={carouselTracks}> <CarouselLayout tracks={carouselTracks}>
<RoomParticipantTile /> <RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
</CarouselLayout> </CarouselLayout>
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile {/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
(см. её исходник), поэтому вместо неё используем свою обёртку (см. её исходник), поэтому вместо неё используем свою обёртку
напрямую с тем же trackRef (аватар в фокус-плитке). */} напрямую с тем же trackRef (аватар в фокус-плитке). */}
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />} {focusTrack && (
<RoomParticipantTile
trackRef={focusTrack}
onStopSharing={handleStopSharing}
pinnedKey={pinnedKey}
onTogglePin={handleTogglePin}
/>
)}
</FocusLayoutContainer> </FocusLayoutContainer>
)} )}
<RoomAudioRenderer /> <RoomAudioRenderer />

View File

@@ -1,9 +1,11 @@
/** /**
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана). * Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
* *
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы, * Никаких зависимостей от React/DOM/LiveKit SDK в РАНТАЙМЕ — на вход только
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера * примитивы, на выход тоже примитивы; можно покрыть unit-тестом при появлении
* (vitest в проект сознательно не вводим, тестируем вручную). * раннера (vitest в проект сознательно не вводим, тестируем вручную).
* Единственный импорт из SDK — `import type` у `stageTrackKey` (тип стирается
* при компиляции, рантайм-зависимости не добавляет).
* *
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной * Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`): * `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
@@ -20,9 +22,21 @@
* однозначность (camera и screen_share одного участника — разные ключи). * однозначность (camera и screen_share одного участника — разные ключи).
*/ */
import type { TrackReferenceOrPlaceholder } from '@livekit/components-react'
/** Вид источника трека на сцене. */ /** Вид источника трека на сцене. */
export type StageFocusKind = 'camera' | 'screen_share' export type StageFocusKind = 'camera' | 'screen_share'
/**
* Ключ трека для `pickStageFocus` — см. обоснование схемы в начале файла.
* Живёт здесь (а не в `RoomStage.tsx`), потому что нужен обеим сторонам:
* сцене — чтобы считать фокус, плитке (`RoomParticipantTile`) — чтобы понять,
* закреплена ли именно она, и каким ключом сообщить о нажатии на «закрепить».
*/
export function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
}
export interface PickStageFocusInput { export interface PickStageFocusInput {
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */ /** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
cameraKeys: readonly string[] cameraKeys: readonly string[]
@@ -35,19 +49,51 @@ export interface PickStageFocusInput {
* `followSpeaker`, для живого переключения фокуса. * `followSpeaker`, для живого переключения фокуса.
*/ */
speakingCameraKeys: readonly string[] speakingCameraKeys: readonly string[]
/**
* Подмножество `cameraKeys` с ЖИВЫМ видео (камера включена и не в мьюте) —
* среди нескольких одновременно говорящих такой участник выигрывает у
* говорящего с выключенной камерой: показывать крупно аватар-заглушку, когда
* рядом говорит человек с картинкой, бессмысленно. Не указан — приоритета
* нет, берётся первый (самый громкий) говорящий, как было раньше.
*/
cameraKeysWithVideo?: readonly string[]
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */ /** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
prevKeys: readonly string[] prevKeys: readonly string[]
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */ /** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null prevFocusKey: string | null
/** /**
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за * Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на
* активным спикером (переключаться сразу, а не удерживать текущий), в * плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет.
* отличие от основного окна сцены — там держим фокус, даже если заговорил * Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про * демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` — * демонстрацией экрана») — а когда демонстрация закончилась, фокус
* поведение основного окна не меняется. * возвращается именно на закреплённого, а не на того, кто был до неё:
* правило закрепления стоит ВЫШЕ удержания предыдущего фокуса.
* Ключ закреплённого участника, покинувшего комнату, игнорируется (его нет
* ни в `cameraKeys`, ни в `screenShareKeys`) — снимает закрепление вызывающая
* сторона.
*/
pinnedKey?: string | null
/**
* Фокус должен ЖИВО следовать за активным спикером (переключаться сразу, а
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в
* основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в
* `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5).
*/ */
followSpeaker?: boolean followSpeaker?: boolean
/**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Нужно основному окну: там демонстрация — это содержательный
* центр разговора, и уводить её из большого окна на каждую реплику нельзя.
* Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе
* — всегда показывает того, кто говорит, поэтому там `false` (поведение
* PiP не менялось с 0.0.4).
*/
holdScreenShare?: boolean
/** /**
* Ключ локального участника (та же схема `identity:source`) — предпоследний * Ключ локального участника (та же схема `identity:source`) — предпоследний
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и * фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
@@ -64,6 +110,20 @@ export interface PickStageFocusResult {
kind: StageFocusKind | null kind: StageFocusKind | null
} }
/**
* Выбирает говорящего, которого стоит показать крупно: среди живых говорящих
* (упорядоченных по громкости) сначала ищем того, у кого включена камера, и
* только если такого нет — берём самого громкого как есть.
*/
function pickSpeakerKey(
speakingCameraKeys: readonly string[],
cameraKeys: readonly string[],
cameraKeysWithVideo: readonly string[],
): string | null {
const liveSpeakers = speakingCameraKeys.filter((key) => cameraKeys.includes(key))
return liveSpeakers.find((key) => cameraKeysWithVideo.includes(key)) ?? liveSpeakers[0] ?? null
}
/** /**
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`). * Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
* *
@@ -72,24 +132,38 @@ export interface PickStageFocusResult {
* фокус безусловно переходит на него (последний из новых, если появилось * фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая * сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet). * демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ * 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник
* переходит на него, даже если текущий фокус ещё жив. В основном окне * забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3. * ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) — * удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в
* фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на
* каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда
* показывает говорящего).
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).
* При `followSpeaker: false` этот шаг пропускается — см. правило 5.
* 5. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава * держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк» * участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»,
* для PiP, когда никто не говорит — держим предыдущего активного. * когда никто не говорит — держим предыдущего активного.
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации * 6. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер, иначе `localKey` (если * над камерой; среди камер — активный спикер (снова с приоритетом камеры),
* указан и жив), иначе первая по порядку. * иначе `localKey` (если указан и жив), иначе первая по порядку.
*/ */
export function pickStageFocus({ export function pickStageFocus({
cameraKeys, cameraKeys,
screenShareKeys, screenShareKeys,
speakingCameraKeys, speakingCameraKeys,
cameraKeysWithVideo = [],
prevKeys, prevKeys,
prevFocusKey, prevFocusKey,
pinnedKey = null,
followSpeaker = false, followSpeaker = false,
holdScreenShare = false,
localKey = null, localKey = null,
}: PickStageFocusInput): PickStageFocusResult { }: PickStageFocusInput): PickStageFocusResult {
if (cameraKeys.length === 0 && screenShareKeys.length === 0) { if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
@@ -102,8 +176,25 @@ export function pickStageFocus({
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' } return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
} }
if (pinnedKey) {
// Закреплена сама демонстрация — она и есть «активная демонстрация»,
// уступать нечему (UI позволяет закрепить любую плитку, включая шэр).
if (screenShareKeys.includes(pinnedKey)) {
return { focusKey: pinnedKey, kind: 'screen_share' }
}
// Закреплена камера: пока в комнате идёт чья-то демонстрация, она
// перебивает закрепление (правило оператора) — идём дальше по списку.
if (cameraKeys.includes(pinnedKey) && screenShareKeys.length === 0) {
return { focusKey: pinnedKey, kind: 'camera' }
}
}
if (holdScreenShare && prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'screen_share' }
}
if (followSpeaker) { if (followSpeaker) {
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key)) const liveSpeaker = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (liveSpeaker) { if (liveSpeaker) {
return { focusKey: liveSpeaker, kind: 'camera' } return { focusKey: liveSpeaker, kind: 'camera' }
} }
@@ -120,7 +211,7 @@ export function pickStageFocus({
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' } return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
} }
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key)) const speaking = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (speaking) { if (speaking) {
return { focusKey: speaking, kind: 'camera' } return { focusKey: speaking, kind: 'camera' }
} }

View File

@@ -1,11 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab' import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
import { AdminUsersTab } from '@/components/admin/AdminUsersTab' import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab' import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab' import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
import { getHealth } from '@/api/health'
import '@/styles/lobby.css' import '@/styles/lobby.css'
import '@/styles/calendar.css' import '@/styles/calendar.css'
import '@/styles/admin.css' import '@/styles/admin.css'
@@ -30,10 +29,6 @@ const TABS: { value: AdminTab; label: string }[] = [
*/ */
export function AdminPage() { export function AdminPage() {
const [tab, setTab] = useState<AdminTab>('conferences') const [tab, setTab] = useState<AdminTab>('conferences')
// Версия инстанса для футера — поле `version` в `/api/health`;
// если его нет в ответе, футер версию просто не показывает
// (см. `HealthOut.version`).
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return ( return (
<div className="admin-page page-shell"> <div className="admin-page page-shell">
@@ -63,11 +58,8 @@ export function AdminPage() {
{tab === 'users' && <AdminUsersTab />} {tab === 'users' && <AdminUsersTab />}
{tab === 'teams' && <AdminTeamsTab />} {tab === 'teams' && <AdminTeamsTab />}
{tab === 'settings' && <AdminSettingsTab />} {tab === 'settings' && <AdminSettingsTab />}
<footer className="admin-footer">
VidConf{health?.version ? ` · v${health.version}` : ''}
</footer>
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar' import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard' import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog' import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
@@ -102,6 +103,7 @@ export function CalendarPage() {
/> />
</div> </div>
</main> </main>
<AppFooter />
{isFormOpen && ( {isFormOpen && (
<ConferenceFormModal <ConferenceFormModal

View File

@@ -9,6 +9,7 @@ import {
} from '@/api/conferences' } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark' import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle' import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { parseJoinQuery } from '@/lib/parseJoinQuery' import { parseJoinQuery } from '@/lib/parseJoinQuery'
@@ -409,6 +410,7 @@ export function JoinPage() {
</section> </section>
)} )}
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Calendar, Link2, User, Video } from 'lucide-react' import { Calendar, Link2, User, Video } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMedallion } from '@/components/ui/LogoMark' import { LogoMedallion } from '@/components/ui/LogoMark'
import { createConference } from '@/api/conferences' import { createConference } from '@/api/conferences'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -113,6 +114,7 @@ export function LobbyPage() {
</div> </div>
</div> </div>
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react' import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard' import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
import { CopyPill } from '@/components/ui/CopyPill' import { CopyPill } from '@/components/ui/CopyPill'
@@ -212,6 +213,7 @@ export function MyConferencesPage() {
</section> </section>
)} )}
</main> </main>
<AppFooter />
{editingConference && ( {editingConference && (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}> <div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>

View File

@@ -2,6 +2,7 @@ import { useRef, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react' import { Trash2, Upload } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { Avatar } from '@/components/ui/Avatar' import { Avatar } from '@/components/ui/Avatar'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -271,6 +272,7 @@ export function ProfilePage() {
</form> </form>
</section> </section>
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -168,10 +168,8 @@
.admin-page .settings-grid { grid-template-columns: 1fr; } .admin-page .settings-grid { grid-template-columns: 1fr; }
} }
/* ---------- Футер админки ---------- /* Футер админки (`.admin-footer`) убран в 0.0.6: вместо него общий для всех
Версия инстанса из `/api/health` (см. AdminPage.tsx) — до появления поля страниц компонент `AppFooter` (см. `styles/footer.css`). */
на backend строка сокращается до одного «VidConf» без версии. */
.admin-page .admin-footer { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--color-border); font: var(--text-caption); color: var(--color-ink-400); text-align: center; }
/* ===================================================================== /* =====================================================================
* Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким * Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким

View File

@@ -0,0 +1,38 @@
/*
* VidConf — общий футер приложения (см. `components/layout/AppFooter.tsx`).
*
* Файл импортируется самим компонентом, а не страницами: футер стоит в четырёх
* разных раскладках (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`),
* и таскать импорт по всем страницам смысла нет.
*
* Футер — обычный блок в потоке (не `position: fixed`), поэтому контент он
* не перекрывает ни на какой высоте экрана.
*/
.app-footer {
padding: var(--space-5) var(--space-4);
font: var(--text-caption);
color: var(--color-ink-400);
text-align: center;
}
.app-footer a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.app-footer a:hover { color: var(--color-ink-700); }
/* Оболочка страниц (`page-shell`): выравниваем футер по ширине контента
(`main.page-main` — max-width 1440 + боковые отступы) и отделяем линией,
как это делал прежний футер админки. */
.page-shell .app-footer {
max-width: 1440px;
margin: 0 auto;
padding: var(--space-4) var(--space-8) var(--space-6);
border-top: 1px solid var(--color-border);
}
/* Экраны auth: футер — последний элемент правой колонки с формой, поэтому
ограничиваем его шириной самой формы (`.screen-block`, 440px), иначе
центрированная строка «уезжала» бы вправо от неё. */
.form-panel .app-footer { max-width: 440px; width: 100%; padding-inline: 0; margin-top: var(--space-8); }
@media (max-width: 640px) {
.page-shell .app-footer { padding: var(--space-4) var(--space-4) var(--space-5); }
}

View File

@@ -416,6 +416,52 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.stage-sharing-chip button:hover { filter: brightness(0.95); } .stage-sharing-chip button:hover { filter: brightness(0.95); }
/*
* ---------- Кнопка закрепления участника (булавка) ----------
* Рендерится внутри `RoomParticipantTile` (см. её `TileBody`), т.е. внутри
* `.lk-participant-tile` — тот у @livekit/components-styles уже
* `position: relative`. Геометрия и логика появления повторяют штатную
* `.lk-focus-toggle-button` той же библиотеки (правый верхний угол, видна по
* наведению) — чтобы плитка выглядела цельно, а не «нашей кнопкой поверх
* чужой». Отличие: у закреплённой плитки булавка видна ВСЕГДА и подсвечена —
* закрепление должно читаться без наведения.
*/
.room-pin-toggle {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 5;
display: flex;
padding: 0.25rem;
border: none;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: rgba(0, 0, 0, 0.5);
color: var(--color-room-text-primary);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
transition-delay: 0.2s;
}
.room-pin-toggle svg { width: 20px; height: 20px; }
.lk-participant-tile:hover .room-pin-toggle,
.lk-participant-tile:focus-within .room-pin-toggle,
.room-pin-toggle.is-pinned { opacity: 1; transition-delay: 0s; }
.room-pin-toggle.is-pinned { background: var(--color-room-mic-on); color: #10331f; }
/* Тач-устройства (мобильный/планшет): hover нет вовсе — иначе булавка была бы
недоступна. Тулбар комнаты не задет: кнопка живёт на плитке. */
@media (hover: none) {
.room-pin-toggle { opacity: 1; transition-delay: 0s; }
}
/* Метка закреплённой плитки — рамка по контуру. Класс закрепления висит на
кнопке (корневой div плитки рендерит библиотека, свой класс туда не
добавить), поэтому селектор идёт через `:has()`. */
.lk-participant-tile:has(.room-pin-toggle.is-pinned) {
outline: 2px solid var(--color-room-mic-on);
outline-offset: -2px;
}
/* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */ /* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */
.room-pip-placeholder { .room-pip-placeholder {
flex: 1; flex: 1;

View File

@@ -162,9 +162,16 @@ async def send_invitations_async(
body = "\n".join(body_lines) body = "\n".join(body_lines)
backend = create_email_backend(settings) backend = create_email_backend(settings)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
for email in recipients: for email in recipients:
try: try:
await backend.send(to=email, subject=subject, body=body, attachments=[attachment]) await backend.send(
to=email,
subject=subject,
body=body,
attachments=[attachment],
reply_to=reply_to,
)
except EmailSendError as exc: except EmailSendError as exc:
if not exc.retryable: if not exc.retryable:
logger.warning( logger.warning(

View File

@@ -156,9 +156,16 @@ async def notify_session_async(
) )
backend = create_email_backend(get_settings()) backend = create_email_backend(get_settings())
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
for email in pending: for email in pending:
try: try:
await backend.send(to=email, subject=subject, body=text_body, html_body=html_body) await backend.send(
to=email,
subject=subject,
body=text_body,
html_body=html_body,
reply_to=reply_to,
)
except EmailSendError as exc: except EmailSendError as exc:
if not exc.retryable: if not exc.retryable:
logger.warning( logger.warning(