Compare commits

..

17 Commits

Author SHA1 Message Date
lebaudantoine 5e3b173d39 📝(docs) document Brevo marketing migration in UPGRADE.md
Explain the switch to django-lasuite marketing: removed BREVO_*
and MARKETING_SERVICE_CLASS settings, new LASUITE_MARKETING_BACKEND
and LASUITE_MARKETING_PARAMETERS variables, dummy backend default,
and the now-required Celery worker.
2026-10-07 12:05:44 +02:00
lebaudantoine 635c05e254 📝(docs) bump UPGRADE.md after v1.33 release
The `Unreleased` section of `UPGRADE.md` was not bumped when v1.33
was released, so follow-up changes landed under the wrong heading.

Reorganize the file so v1.33 is properly closed and a fresh
`Unreleased` section is opened on top.
2026-10-07 12:05:42 +02:00
lebaudantoine 258722adfd ⏪️(docs) revert styling on heartbeat and lbheartbeat probe in CHANGELOG
Revert the formatting changes applied to the heartbeat and
lbheartbeat probe entries in the CHANGELOG, as the previous styling
was intentional.
2026-10-07 11:49:23 +02:00
lebaudantoine 0a6724e7ad 🔧(compose) make the LiveKit agents opt-in in the local dev stack
`make run` no longer starts the metadata collector and the
multi-user transcriber. On my machine, this saves about 400 MB
of RAM and almost 2% CPU, out of the 11% the stack uses.
Start them with `make run-agents` when needed.

Disable `METADATA_COLLECTOR_ENABLED` by default in `common.dist` so
the backend does not dispatch jobs to an agent that is not running,
and document how to enable each agent in `developping_locally.md`.
2026-10-06 22:56:57 +02:00
lebaudantoine c1c2d93932 🔧(compose) share the backend redis with the summary stack
The summary settings already default to the `redis` host, so
`redis-summary` was unused. Remove it and depend on `redis`.

Pin the summary Celery and task tracker URLs to DB 2 in
`summary.dist` to isolate them from LiveKit and the backend
Celery broker (DB 0) and the Django cache (DB 1).
2026-10-06 17:26:26 +02:00
lebaudantoine 9efb9577c4 ♻️(tilt) use the helm dev-backend chart
Use the shared helm `dev-backend` chart to deploy the backend in
the Tilt dev stack, instead of maintaining our own copy.

The goal is to share more common pieces of the dev experience and
dev stack across projects, so we do not maintain N different
versions of similar setups.

The only adjustment needed was to backport Garage into the shared
chart.

Originally started by @rouja.
2026-10-06 14:07:23 +02:00
Ovgodd 2137c6b444 🐛(frontend) let panel shortcuts close panels opened from a menu
allow menu-invoked panels to be closed by panel shortcuts
2026-10-05 15:11:04 +02:00
Ovgodd aa01733f6b ♿️(frontend) make participant pagination readable and keyboard reachable
Improves PaginationControl: clearer structure, keyboard nav, a11y improved.
The main room and the picture-in-picture window share this control.
Ctrl+Shift+G focuses the pagination.
2026-10-05 15:11:04 +02:00
lebaudantoine 03db669e51 🔒️(ci) set persist-credentials: false on actions/checkout
By default, `actions/checkout` saves the job's auth token
(`GITHUB_TOKEN` or the provided PAT) in the local git config so
later steps can run authenticated git commands. That token then
stays on disk for the rest of the job, where it can leak:

* If an artifact upload includes the checkout directory, the token
  is packaged with it and anyone with artifact access can extract
  it. On public repos that is anyone, and the token can be used
  while the job is still running ("ArtiPACKED", flagged by
  `zizmor` as `artipacked`).
* Any later step, third-party action, or build dependency can read
  the token from the git config, which widens the impact of a
  supply-chain compromise.

None of our workflows need authenticated git after checkout, so
disable credential persistence. If a step needs to push in
2026-10-05 12:41:38 +02:00
davd-gzl 97a73bf4f2 🔧(frontend) rename the make target to test-frontend
The frontend test target now sits beside test-back and
test-summary and follows their naming.
2026-10-05 12:41:38 +02:00
davd-gzl 267a2265d9 🔧(frontend) give vitest its own config and a make target
A separate vitest.config.ts keeps the tests off the build
plugins and the mediapipe version check in vite.config.ts.
make test now runs the frontend tests after the backend ones,
and test-front runs on Node 24, the current LTS.
2026-10-05 12:41:38 +02:00
davd-gzl 14507d57a9 ✅(frontend) add vitest so the frontend can carry unit tests
Add vitest as a dev dependency, a test script that runs panda
codegen first, and a test-front job, so the frontend can carry
unit tests. One test covers normalizeRoomId, a plain function,
so no DOM library comes with it.
2026-10-05 12:41:38 +02:00
davd-gzl c075db25a4 🐛(frontend) honour Keep hand raised when picture-in-picture is open
The hand button owns the lower-hand timer and the toast, and the
picture-in-picture window draws a second copy of that button, so two
offers go up and dismissing one leaves the other to lower the hand.
Move the watching into a component rendered once beside the room.
2026-10-04 23:21:31 +02:00
Rishi Gupta c0dfd88776 ♿️(frontend) expose loading state to assistive technology
Surface the page's loading state to assistive technology, so screen
readers can announce that the page is still loading instead of
reading a partially rendered state as if it were complete.
2026-10-04 22:58:21 +02:00
Rishi Gupta 94d2b80c17 ✏️(docs) fix README wording and typos
Address review feedback from #1 by fixing wording issues and typos
across the README.
2026-10-04 22:58:21 +02:00
KusalPabasara bd2dfcae80 ✨(helm) add envFrom support to chart
Allow every chart workload to import environment variables from ConfigMaps.

Keep existing empty defaults for backward compatibility.
2026-10-04 22:34:31 +02:00
lebaudantoine 0b4a83c92e 🐛(brevo) use django-lasuite for marketing management
When the user is updated, their lists on Brevo are overwritten with
the new value: this removes lists set by other products.

Switch to the common lib implementation from `django-lasuite`,
which manages this correctly.

This change was initially proposed by @qbey, but at the time our
deployment did not have a Celery worker running alongside the
backend. Since then, a Celery worker has been deployed, so the
switch to the common lib approach is now safe to adopt.
2026-10-04 22:10:16 +02:00
50 changed files with 784 additions and 781 deletions
+31
View File
@@ -88,6 +88,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Create writable /data - name: Create writable /data
run: | run: |
@@ -159,6 +161,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install ffmpeg - name: Install ffmpeg
run: | run: |
@@ -186,6 +190,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies - name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts run: cd src/frontend/ && npm ci --ignore-scripts
@@ -196,6 +202,27 @@ jobs:
- name: Check format - name: Check format
run: cd src/frontend/ && npm run check run: cd src/frontend/ && npm run check
test-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "24"
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
- name: Run tests
run: cd src/frontend/ && npm test
lint-sdk: lint-sdk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -206,6 +233,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies - name: Install dependencies
run: npm ci --ignore-scripts run: npm ci --ignore-scripts
@@ -227,6 +256,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies - name: Install dependencies
run: npm ci --ignore-scripts run: npm ci --ignore-scripts
+7
View File
@@ -10,11 +10,14 @@ and this project adheres to
### Added ### Added
- ✨(helm) import environment variables from Secrets and ConfigMaps
- 🔒(backend) throttle meeting link generation - 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation - 🔒️(backend) add a daily cap on room creation
- 🔧(summary) add setting to control Sentry traces sampling rate - 🔧(summary) add setting to control Sentry traces sampling rate
- ✨(frontend) let signed-out visitors start a meeting - ✨(frontend) let signed-out visitors start a meeting
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration - ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
- ✅(frontend) add vitest so the frontend can carry unit tests
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
### Changed ### Changed
@@ -31,6 +34,9 @@ and this project adheres to
- 🐛(summary) disable default S3 checksums for GCS-compatible storage - 🐛(summary) disable default S3 checksums for GCS-compatible storage
- 🔒️(summary) redact meeting content from Sentry events - 🔒️(summary) redact meeting content from Sentry events
- 🐛(frontend) hide tooltips until they have a computed placement - 🐛(frontend) hide tooltips until they have a computed placement
- 🐛(brevo) use django-lasuite for marketing management
- ♿️(frontend) expose loading state to assistive technology
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
## [1.33.0] - 2026-09-30 ## [1.33.0] - 2026-09-30
@@ -161,6 +167,7 @@ and this project adheres to
### Added ### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms - ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed ### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports - 📱(frontend) collapse mobile control bar items on narrow viewports
+7 -3
View File
@@ -169,7 +169,7 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize @$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary .PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
@$(MAKE) run-agent-multi-user-transcriber @$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector @$(MAKE) run-agent-metadata-collector
.PHONY: run-agents .PHONY: run-agents
@@ -186,7 +186,6 @@ run:
run: ## start the wsgi (production) and development server run: ## start the wsgi (production) and development server
@$(MAKE) run-backend @$(MAKE) run-backend
@$(MAKE) run-summary @$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend @$(COMPOSE) up --force-recreate -d frontend
.PHONY: run .PHONY: run
@@ -260,7 +259,8 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"` test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \ $(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}" $(MAKE) test-summary ARGS="$${args}" && \
$(MAKE) test-frontend
.PHONY: test .PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS) test-back: ## run back-end tests (pass extra pytest args via ARGS)
@@ -278,6 +278,10 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
bin/pytest-summary $${args} bin/pytest-summary $${args}
.PHONY: test-summary .PHONY: test-summary
test-frontend: ## run the frontend unit tests
cd $(PATH_FRONT) && npm test
.PHONY: test-frontend
makemigrations: ## run django makemigrations for the Meet project. makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)" @echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql @$(COMPOSE) up -d postgresql
+3 -3
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/> <img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE"> <a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/> <img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a> </a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing"> <a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/> <img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Telephony integration - Telephony integration
- Secure participation with robust authentication and access control - Secure participation with robust authentication and access control
- Customizable frontend style - Customizable frontend style
- LiveKit Advances features including : - LiveKit advanced features including:
- speaker detection - speaker detection
- simulcast - simulcast
- end-to-end optimizations - end-to-end optimizations
- selective subscription - selective subscription
+76 -25
View File
@@ -16,43 +16,54 @@ the following command inside your docker container:
## [Unreleased] ## [Unreleased]
### Purging inactive rooms ### Marketing / Brevo integration now uses `django-lasuite`
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md). The in-house marketing service (`core.services.marketing`) has been removed and
replaced by the shared implementation from `django-lasuite`
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
Brevo overwrote their list memberships, removing lists set by other
La Suite products. Existing lists are now preserved and merged.
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`. **Celery worker required.** Newsletter signup on login
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading. (`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window. asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged. instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL. running alongside the backend, otherwise contacts will never be pushed to Brevo.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights. **Configuration changes.** The following environment variables / settings are
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database. **removed** and no longer read:
### Local development: MinIO replaced by Garage - `MARKETING_SERVICE_CLASS`
- `BREVO_API_KEY`
- `BREVO_API_CONTACT_LIST_IDS`
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
- `BREVO_API_TIMEOUT`
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost. They are replaced by a single `LASUITE_MARKETING` setting, configured through:
To migrate a local environment: | Variable | Default | Description |
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans` ⚠️ The default backend is now a **dummy** (no-op). If you previously used
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media` Brevo, you must explicitly configure it, otherwise signups are silently dropped:
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
### Summary service and metadata collector: boto3 replaces the minio client LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings. Migration mapping:
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
Also: - `BREVO_API_KEY` → `api_key`
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs - `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`. - `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
`{"VISIO_USER": True}` if you relied on the old default)
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
### Helm chart: media services default to Garage Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
secret (e.g. build the whole variable from your secret store).
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
### Recording encoding settings replaced by a resolution/profile model ### Recording encoding settings replaced by a resolution/profile model
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
@@ -175,6 +186,46 @@ Before enabling it:
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding) See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
for the full setting reference, the shipped profile table and the tuning caveats. for the full setting reference, the shipped profile table and the tuning caveats.
## v1.33.0
### Purging inactive rooms
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
### Local development: MinIO replaced by Garage
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
To migrate a local environment:
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
### Summary service and metadata collector: boto3 replaces the minio client
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
Also:
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
### Helm chart: media services default to Garage
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
## v1.30.0 ## v1.30.0
### Removing S3 storage-event webhooks for recordings ### Removing S3 storage-event webhooks for recordings
+5 -8
View File
@@ -9,6 +9,8 @@ services:
redis: redis:
image: redis:5 image: redis:5
ports:
- "6379:6379"
mailcatcher: mailcatcher:
image: sj26/mailcatcher:latest image: sj26/mailcatcher:latest
@@ -271,11 +273,6 @@ services:
- ./src/agents:/app - ./src/agents:/app
- /app/.venv - /app/.venv
redis-summary:
image: redis:5
ports:
- "6379:6379"
app-summary-dev: app-summary-dev:
build: build:
context: src/summary context: src/summary
@@ -290,7 +287,7 @@ services:
volumes: volumes:
- ./src/summary:/app - ./src/summary:/app
depends_on: depends_on:
- redis-summary - redis
celery-summary-transcribe: celery-summary-transcribe:
container_name: celery-summary-transcribe container_name: celery-summary-transcribe
@@ -304,7 +301,7 @@ services:
volumes: volumes:
- ./src/summary:/app - ./src/summary:/app
depends_on: depends_on:
- redis-summary - redis
- app-summary-dev - app-summary-dev
- garage - garage
develop: develop:
@@ -324,7 +321,7 @@ services:
volumes: volumes:
- ./src/summary:/app - ./src/summary:/app
depends_on: depends_on:
- redis-summary - redis
- app-summary-dev - app-summary-dev
- garage - garage
develop: develop:
+31
View File
@@ -107,6 +107,37 @@ $ npm i
$ npm run dev $ npm run dev
``` ```
### LiveKit agents (optional)
The LiveKit agents are not started by `make run`. Each one runs its own
container and stays connected to LiveKit, which costs CPU and memory you
don't need unless you work on the features they power. Start them only
when you need them.
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|---|---|---|---|
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
To start both at once:
```shellscript
$ make run-agents
```
Then set the matching settings to `True` and restart the backend so it
picks them up:
```shellscript
$ make run-backend
```
The multi-user transcriber also needs a speech-to-text provider. Configure
`STT_PROVIDER` and its credentials in
`env.d/development/multi_user_transcriber`.
Keep the settings and the agents in sync: if a setting is `True` while its
agent is stopped, the backend still dispatches jobs to it and the feature
fails silently.
--- ---
## Adding Content ## Adding Content
+4 -3
View File
@@ -104,10 +104,11 @@ ROOM_TELEPHONY_ENABLED=True
# ROOMKIT_ENABLED = True # ROOMKIT_ENABLED = True
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly # ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
# Metadata # LiveKit agents (opt-in, start them with `make run-agents`)
METADATA_COLLECTOR_ENABLED=True # Metadata (requires the metadata-collector agent)
METADATA_COLLECTOR_ENABLED=False
# Subtitle # Subtitle (requires the multi-user-transcriber agent)
ROOM_SUBTITLE_ENABLED=False ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False FRONTEND_USE_FRENCH_GOV_FOOTER=False
+4
View File
@@ -9,6 +9,10 @@ AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key" AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local" AWS_S3_REGION_NAME="local"
CELERY_BROKER_URL="redis://redis:6379/2"
CELERY_RESULT_BACKEND="redis://redis:6379/2"
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
WHISPERX_BASE_URL="https://configure-your-url.com" WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2" WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key" WHISPERX_API_KEY="your-secret-key"
+8 -25
View File
@@ -1,26 +1,19 @@
"""Authentication Backends for the Meet core app.""" """Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings from django.conf import settings
from django.core.exceptions import ( from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation, SuspiciousOperation,
ValidationError, ValidationError,
) )
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
from rest_framework.authentication import SessionAuthentication from rest_framework.authentication import SessionAuthentication
from core.models import User from core.models import User
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
from core.validators import sub_validator from core.validators import sub_validator
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@staticmethod @staticmethod
def signup_to_marketing_email(email): def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow. """Add the user to the newsletter list on sign-in.
Details: Uses the team's standard Brevo integration, dispatching the contact
1. Uses a very short timeout (1s) to prevent blocking the auth process creation/update as an asynchronous task to keep authentication fast.
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
""" """
with contextlib.suppress( create_or_update_contact.delay(
ContactCreationError, ImproperlyConfigured, ImportError email=email,
): attributes={"VISIO_SOURCE": ["SIGNIN"]},
marketing_service = get_marketing_service() )
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
def get_existing_user(self, sub, email): def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email.""" """Fetch existing user by sub or email."""
-138
View File
@@ -1,138 +0,0 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -2,14 +2,14 @@
from unittest import mock from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.core.exceptions import SuspiciousOperation
import pytest import pytest
from lasuite.marketing.tasks import create_or_update_contact
from core import models from core import models
from core.authentication.backends import OIDCAuthenticationBackend from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
mock_signup.assert_not_called() mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service") @mock.patch.object(create_or_update_contact, "delay")
def test_signup_to_marketing_email_success(mock_marketing): def test_signup_to_marketing_email_success(mock_create_or_update_contact):
"""Test successful marketing signup.""" """Test successful marketing signup."""
email = "test@example.com" email = "test@example.com"
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
OIDCAuthenticationBackend.signup_to_marketing_email(email) OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction # Verify service interaction
mock_service = mock_marketing.return_value mock_create_or_update_contact.assert_called_once_with(
mock_service.create_contact.assert_called_once() email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@@ -1,212 +0,0 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@mock.patch("core.services.marketing.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
+12 -18
View File
@@ -938,24 +938,18 @@ class Base(Configuration):
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL", environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None, environ_prefix=None,
) )
MARKETING_SERVICE_CLASS = values.Value( LASUITE_MARKETING = {
"core.services.marketing.BrevoMarketingService", "BACKEND": values.Value(
environ_name="MARKETING_SERVICE_CLASS", "lasuite.marketing.backends.dummy.DummyBackend",
environ_prefix=None, environ_name="LASUITE_MARKETING_BACKEND",
) environ_prefix=None,
BREVO_API_KEY = SecretFileValue( ),
None, environ_name="BREVO_API_KEY", environ_prefix=None "PARAMETERS": values.DictValue(
) default={},
BREVO_API_CONTACT_LIST_IDS = values.ListValue( environ_name="LASUITE_MARKETING_PARAMETERS",
[], environ_prefix=None,
environ_name="BREVO_API_CONTACT_LIST_IDS", ),
environ_prefix=None, }
converter=int,
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
# Lobby configurations # Lobby configurations
PRESENCE_KEY_PREFIX = values.Value( PRESENCE_KEY_PREFIX = values.Value(
+278 -4
View File
@@ -64,7 +64,8 @@
"typescript-eslint": "8.60.1", "typescript-eslint": "8.60.1",
"vite": "8.0.14", "vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1", "vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0" "vite-plugin-svgr": "5.2.0",
"vitest": "5.0.2"
} }
}, },
"node_modules/@adobe/react-spectrum": { "node_modules/@adobe/react-spectrum": {
@@ -942,9 +943,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2506,6 +2507,24 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": { "node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22", "version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
@@ -2786,6 +2805,64 @@
} }
} }
}, },
"node_modules/@vitest/mocker": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.2",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.25", "version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
@@ -3151,6 +3228,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types-flow": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4130,6 +4217,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5030,6 +5127,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5532,6 +5636,16 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -8647,6 +8761,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -10220,6 +10348,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/std-env": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -10526,6 +10661,26 @@
"xtend": "~4.0.1" "xtend": "~4.0.1"
} }
}, },
"node_modules/tinybench": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -11610,6 +11765,112 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/vitest": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.2",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "^6.1.4",
"tinyexec": "^1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^3.2.1"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.2",
"@vitest/browser-preview": "5.0.2",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.2",
"@vitest/coverage-v8": "5.0.2",
"@vitest/ui": "5.0.2",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/walk-sync": { "node_modules/walk-sync": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
@@ -11794,6 +12055,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
"dev": true,
"license": "MIT",
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=20.11"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+3 -1
View File
@@ -7,6 +7,7 @@
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build", "build": "panda codegen && tsc -b && vite build",
"build:debug": "VITE_ANALYZE=true npm run build -- --debug", "build:debug": "VITE_ANALYZE=true npm run build -- --debug",
"test": "panda codegen && vitest run",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"preview": "vite preview", "preview": "vite preview",
@@ -71,6 +72,7 @@
"typescript-eslint": "8.60.1", "typescript-eslint": "8.60.1",
"vite": "8.0.14", "vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1", "vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0" "vite-plugin-svgr": "5.2.0",
"vitest": "5.0.2"
} }
} }
@@ -19,7 +19,9 @@ export const LoadingScreen = ({
<Screen layout={layout} header={header} footer={footer}> <Screen layout={layout} header={header} footer={footer}>
<CenteredContent> <CenteredContent>
<Center> <Center>
<p>{t('loading')}</p> <p role="status" aria-live="polite">
{t('loading')}
</p>
</Center> </Center>
</CenteredContent> </CenteredContent>
</Screen> </Screen>
@@ -7,7 +7,6 @@ import {
useSwipe, useSwipe,
} from '@livekit/components-react' } from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps' import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout' import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl' import { PaginationControl } from './PaginationControl'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger' import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
@@ -62,13 +61,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
> >
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop> <TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > layout.maxTiles && ( {tracks.length > layout.maxTiles && (
<> <PaginationControl {...pagination} focusShortcut />
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
/>
<PaginationControl pagesContainer={gridEl} {...pagination} />
</>
)} )}
</div> </div>
) )
@@ -1,99 +1,147 @@
import * as React from 'react'
import { createInteractingObservable } from '@livekit/components-core'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css, cva, type RecipeVariantProps } from '@/styled-system/css'
import { useCallback, useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export interface PaginationControlProps { const paginationToolbar = cva({
base: {
display: 'flex',
alignItems: 'center',
gap: '0.125rem',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
border: '1px solid',
borderColor: 'primaryDark.200',
padding: '0.375rem',
},
variants: {
placement: {
overlay: {
position: 'absolute',
bottom: '1rem',
left: '50%',
zIndex: 2,
transform: 'translateX(-50%)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.45)',
},
inline: {
alignSelf: 'center',
flexShrink: 0,
marginTop: '1rem',
},
},
},
defaultVariants: {
placement: 'overlay',
},
})
export type PaginationControlProps = RecipeVariantProps<
typeof paginationToolbar
> & {
totalPageCount: number totalPageCount: number
nextPage: () => void nextPage: () => void
prevPage: () => void prevPage: () => void
currentPage: number currentPage: number
pagesContainer?: React.RefObject<HTMLElement> // The shortcut listens on the main window only, so a single instance may own it.
focusShortcut?: boolean
} }
const arrowButtonClass = css({
_disabled: {
cursor: 'default',
backgroundColor: 'transparent !important',
'& svg': {
opacity: 0.35,
},
_focusVisible: {
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: '2px',
},
},
})
export function PaginationControl({ export function PaginationControl({
totalPageCount, totalPageCount,
nextPage, nextPage,
prevPage, prevPage,
currentPage, currentPage,
pagesContainer: connectedElement, placement,
focusShortcut = false,
}: PaginationControlProps) { }: PaginationControlProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' }) const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
const [interactive, setInteractive] = useState(false) const prevButtonRef = useRef<HTMLButtonElement>(null)
const nextButtonRef = useRef<HTMLButtonElement>(null)
const isSinglePage = totalPageCount <= 1
const isFirstPage = currentPage <= 1
const isLastPage = currentPage >= totalPageCount
useEffect(() => { const focusPagination = useCallback(() => {
let subscription: const target = isLastPage ? prevButtonRef.current : nextButtonRef.current
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']> target?.focus()
| undefined }, [isLastPage])
if (connectedElement) {
subscription = createInteractingObservable(
connectedElement.current,
2000
).subscribe(setInteractive)
}
return () => {
if (subscription) {
subscription.unsubscribe()
}
}
}, [connectedElement])
if (totalPageCount <= 1) return null useRegisterKeyboardShortcut({
id: focusShortcut ? 'focus-pagination' : undefined,
handler: focusPagination,
isDisabled: isSinglePage,
unregisterOnUnmount: true,
})
if (isSinglePage) return null
const pageCount = t('count', { currentPage, totalPageCount })
return ( return (
<nav <div
aria-label={t('label')} role="group"
className={css({ aria-label={`${t('label')}, ${pageCount}`}
position: 'absolute', className={paginationToolbar({ placement })}
bottom: '1rem',
left: '50%',
transform: 'translateX(-50%)',
alignItems: 'stretch',
backgroundColor: 'var(--lk-control-bg)',
borderRadius: 'var(--lk-border-radius)',
transition: 'opacity ease-in-out .15s',
display: 'none',
border: '1px solid',
borderColor: 'primaryDark.100',
overflow: 'hidden',
})}
style={{
display: interactive ? 'flex' : 'none',
}}
data-lk-user-interaction={interactive}
> >
<Button <Button
isDisabled={currentPage == 1} ref={prevButtonRef}
onPress={prevPage} aria-disabled={isFirstPage}
size="xs" onPress={isFirstPage ? undefined : prevPage}
variant="quaternaryText" size="sm"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('previous')} aria-label={t('previous')}
tooltip={t('previous')}
> >
<RiArrowLeftSLine /> <RiArrowLeftSLine size={20} />
</Button> </Button>
<span <span
role="status" role="status"
className={css({ className={css({
padding: '0.25rem 0.5rem', color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.5rem',
textAlign: 'center',
userSelect: 'none',
padding: '0 0.35rem',
whiteSpace: 'nowrap',
})} })}
> >
{t('count', { {pageCount}
currentPage,
totalPageCount,
})}
</span> </span>
<Button <Button
isDisabled={currentPage == totalPageCount} ref={nextButtonRef}
onPress={nextPage} aria-disabled={isLastPage}
size="xs" onPress={isLastPage ? undefined : nextPage}
variant="quaternaryText" size="sm"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('next')} aria-label={t('next')}
tooltip={t('next')}
> >
<RiArrowRightSLine /> <RiArrowRightSLine size={20} />
</Button> </Button>
</nav> </div>
) )
} }
@@ -1,30 +0,0 @@
import * as React from 'react'
export interface PaginationIndicatorProps {
totalPageCount: number
currentPage: number
}
export const PaginationIndicator: (
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
PaginationIndicatorProps
>(function PaginationIndicator(
{ totalPageCount, currentPage }: PaginationIndicatorProps,
ref
) {
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
if (index + 1 === currentPage) {
return <span data-lk-active key={index} />
} else {
return <span key={index} />
}
})
return (
<div ref={ref} className="lk-pagination-indicator" aria-hidden="true">
{bubbles}
</div>
)
})
@@ -1,96 +0,0 @@
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
interface PipPaginationProps {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
export const PipPagination = ({
totalPageCount,
currentPage,
nextPage,
prevPage,
}: PipPaginationProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
if (totalPageCount <= 1) return null
return (
<Nav aria-label={t('label')}>
<ArrowButton
type="button"
onClick={prevPage}
disabled={currentPage === 1}
aria-label={t('previous')}
>
<RiArrowLeftSLine size={18} />
</ArrowButton>
<Counter role="status">
{t('count', { currentPage, totalPageCount })}
</Counter>
<ArrowButton
type="button"
onClick={nextPage}
disabled={currentPage === totalPageCount}
aria-label={t('next')}
>
<RiArrowRightSLine size={18} />
</ArrowButton>
</Nav>
)
}
const Nav = styled('nav', {
base: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
marginTop: '1rem',
flexShrink: 0,
},
})
const ArrowButton = styled('button', {
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1.75rem',
height: '1.75rem',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
color: 'white',
backgroundColor: 'primaryDark.100',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover:not(:disabled)': {
backgroundColor: 'primaryDark.75',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'white',
outlineOffset: '2px',
},
'&:disabled': {
opacity: 0.3,
cursor: 'default',
},
},
})
const Counter = styled('span', {
base: {
fontSize: '0.75rem',
color: 'white',
opacity: 0.8,
whiteSpace: 'nowrap',
padding: '0 0.25rem',
minWidth: '3rem',
textAlign: 'center',
},
})
@@ -4,7 +4,7 @@ import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout' import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout' import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination' import { PaginationControl } from '@/features/layout/components/PaginationControl'
import { PipScreenShareLayout } from './PipScreenShareLayout' import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame' import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid' import { MAX_PIP_TILES } from '../../utils/pipGrid'
@@ -120,12 +120,7 @@ const PaginatedStage = ({
}) => ( }) => (
<StageWrapper> <StageWrapper>
<StageFrame>{children}</StageFrame> <StageFrame>{children}</StageFrame>
<PipPagination <PaginationControl {...pagination} placement="inline" />
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper> </StageWrapper>
) )
@@ -0,0 +1,43 @@
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
const SPEAKING_DETECTION_DELAY = 3000
/**
* Offers to lower the local participant's raised hand after
* SPEAKING_DETECTION_DELAY of speaking. Mount it once: each copy runs its own
* timer and shows its own toast.
*/
export const LowerHandOnSpeaking = () => {
const room = useRoomContext()
const { isHandRaised, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const [hasOffered, setHasOffered] = useState(false)
useEffect(() => {
if (isHandRaised) return
setHasOffered(false)
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
if (!isSpeaking || !isHandRaised || hasOffered) return
const timer = setTimeout(() => {
setHasOffered(true)
showLowerHandToast(room.localParticipant, lowerHand)
}, SPEAKING_DETECTION_DELAY)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasOffered])
return null
}
@@ -2,19 +2,12 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe' import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({ export const HandToggle = ({
@@ -25,64 +18,15 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext() const room = useRoomContext()
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({ const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant, participant: room.localParticipant,
}) })
const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
const resetToastState = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'raise-hand', id: 'raise-hand',
handler: handleToggle, handler: toggleRaisedHand,
}) })
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
lowerHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
}, SPEAKING_DETECTION_DELAY)
}
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise' const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return ( return (
@@ -100,7 +44,7 @@ export const HandToggle = ({
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isHandRaised} isSelected={isHandRaised}
onPress={(e) => { onPress={(e) => {
handleToggle() toggleRaisedHand()
onPress?.(e) onPress?.(e)
}} }}
data-attr={`controls-hand-${tooltipLabel}`} data-attr={`controls-hand-${tooltipLabel}`}
@@ -31,35 +31,20 @@ export const useSidePanel = () => {
const isSidePanelOpen = !!activePanelId const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => { // Reads the live store, not the render snapshot: shortcut handlers outlive
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN // toggles that unmount (e.g. inside a closed overflow menu).
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null const togglePanel = (panelId: PanelId) => {
layoutStore.activePanelId =
layoutStore.activePanelId === panelId ? null : panelId
if (layoutStore.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleParticipants = () => { const toggleAdmin = () => togglePanel(PanelId.ADMIN)
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS const toggleParticipants = () => togglePanel(PanelId.PARTICIPANTS)
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null const toggleChat = () => togglePanel(PanelId.CHAT)
} const toggleEffects = () => togglePanel(PanelId.EFFECTS)
const toggleTools = () => togglePanel(PanelId.TOOLS)
const toggleChat = () => { const toggleInfo = () => togglePanel(PanelId.INFO)
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => { const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
@@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider' import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector' import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LowerHandOnSpeaking } from '@/features/rooms/livekit/components/LowerHandOnSpeaking'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider' import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/** /**
@@ -119,6 +120,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionObserver /> <ConnectionObserver />
<SyncDevicePreferences /> <SyncDevicePreferences />
<RoomSilentMicDetector /> <RoomSilentMicDetector />
<LowerHandOnSpeaking />
<MediaStateObserver /> <MediaStateObserver />
<ChatProvider /> <ChatProvider />
<LobbyProvider /> <LobbyProvider />
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { normalizeRoomId } from './isRoomValid'
describe('normalizeRoomId', () => {
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
expect(normalizeRoomId('ABCDEFGHIJ')).toBe('abc-defg-hij')
expect(normalizeRoomId('abc-defghij')).toBe('abc-defg-hij')
})
it('returns any other input unchanged', () => {
expect(normalizeRoomId('abc-def')).toBe('abc-def')
expect(normalizeRoomId('Not-A-Room')).toBe('Not-A-Room')
})
})
@@ -7,6 +7,7 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId = export type ShortcutId =
| 'open-shortcuts' | 'open-shortcuts'
| 'focus-toolbar' | 'focus-toolbar'
| 'focus-pagination'
| 'toggle-microphone' | 'toggle-microphone'
| 'toggle-camera' | 'toggle-camera'
| 'push-to-talk' | 'push-to-talk'
@@ -44,6 +45,11 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'navigation', category: 'navigation',
shortcut: { key: 'F2' }, shortcut: { key: 'F2' },
}, },
{
id: 'focus-pagination',
category: 'navigation',
shortcut: { key: 'G', ctrlKey: true, shiftKey: true },
},
{ {
id: 'toggle-microphone', id: 'toggle-microphone',
category: 'media', category: 'media',
@@ -7,12 +7,16 @@ export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId id?: ShortcutId
handler: () => Promise<void | boolean | undefined> | void handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean isDisabled?: boolean
// Opt-in: controls rendered inside menus unmount when the menu closes, and
// their shortcuts must keep working.
unregisterOnUnmount?: boolean
} }
export const useRegisterKeyboardShortcut = ({ export const useRegisterKeyboardShortcut = ({
id, id,
handler, handler,
isDisabled = false, isDisabled = false,
unregisterOnUnmount = false,
}: useRegisterKeyboardShortcutProps) => { }: useRegisterKeyboardShortcutProps) => {
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@@ -21,8 +25,14 @@ export const useRegisterKeyboardShortcut = ({
const formattedKey = formatShortcutKey(descriptor.shortcut) const formattedKey = formatShortcutKey(descriptor.shortcut)
if (isDisabled) { if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey) keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else { return
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
} }
}, [handler, id, isDisabled]) keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
if (!unregisterOnUnmount) return
return () => {
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
}
}
}, [handler, id, isDisabled, unregisterOnUnmount])
} }
+1
View File
@@ -784,6 +784,7 @@
"actions": { "actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen", "open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste", "focus-toolbar": "Fokus auf die untere Symbolleiste",
"focus-pagination": "Fokus auf die Teilnehmerseiten",
"toggle-microphone": "Mikrofon umschalten", "toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten", "toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk", "push-to-talk": "Push-to-talk",
+1
View File
@@ -784,6 +784,7 @@
"actions": { "actions": {
"open-shortcuts": "Open shortcuts help", "open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar", "focus-toolbar": "Focus bottom toolbar",
"focus-pagination": "Focus participant pagination",
"toggle-microphone": "Toggle microphone", "toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera", "toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)", "push-to-talk": "Push-to-talk (hold to unmute)",
+1
View File
@@ -783,6 +783,7 @@
"actions": { "actions": {
"open-shortcuts": "Abrir la ayuda de atajos", "open-shortcuts": "Abrir la ayuda de atajos",
"focus-toolbar": "Poner el foco en la barra de herramientas inferior", "focus-toolbar": "Poner el foco en la barra de herramientas inferior",
"focus-pagination": "Poner el foco en la paginación de participantes",
"toggle-microphone": "Activar o desactivar el micrófono", "toggle-microphone": "Activar o desactivar el micrófono",
"toggle-camera": "Activar o desactivar la cámara", "toggle-camera": "Activar o desactivar la cámara",
"push-to-talk": "Pulsar para hablar (mantener para reactivar)", "push-to-talk": "Pulsar para hablar (mantener para reactivar)",
+1
View File
@@ -784,6 +784,7 @@
"actions": { "actions": {
"open-shortcuts": "Ouvrir l’aide des raccourcis", "open-shortcuts": "Ouvrir l’aide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas", "focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
"focus-pagination": "Mettre le focus sur la pagination des participants",
"toggle-microphone": "Activer ou désactiver le micro", "toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra", "toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)", "push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
+1
View File
@@ -784,6 +784,7 @@
"actions": { "actions": {
"open-shortcuts": "Sneltoetsenhulp openen", "open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk", "focus-toolbar": "Focus op de onderste werkbalk",
"focus-pagination": "Focus op de paginering van deelnemers",
"toggle-microphone": "Microfoon aan/uit", "toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit", "toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)", "push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
+1 -1
View File
@@ -10,5 +10,5 @@
"noEmit": true, "noEmit": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "vitest.config.ts"]
} }
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
// Kept apart from vite.config.ts so the tests load none of the build plugins.
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
})
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.agentMetadata.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.agentSubtitles.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -41,6 +41,10 @@ items:
imagePullPolicy: {{ ($.Values.backend.image | default dict).pullPolicy | default $.Values.image.pullPolicy }} imagePullPolicy: {{ ($.Values.backend.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
args: args:
{{- toYaml .command | nindent 22 }} {{- toYaml .command | nindent 22 }}
{{- with $.Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 20 }}
{{- end }}
env: env:
{{- if $envVars}} {{- if $envVars}}
{{- $envVars | indent 22 }} {{- $envVars | indent 22 }}
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -49,6 +49,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -49,6 +49,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -1,4 +1,5 @@
{{- $envVars := include "meet.env.transformDict" (mergeOverwrite (default dict .Values.backend.envVars) (default dict .Values.celeryBackend.envVars)) -}} {{- $envVars := include "meet.env.transformDict" (mergeOverwrite (default dict .Values.backend.envVars) (default dict .Values.celeryBackend.envVars)) -}}
{{- $envFrom := concat (default (list) .Values.backend.envFrom) (default (list) .Values.celeryBackend.envFrom) -}}
{{- $fullName := include "meet.celeryBackend.fullname" . -}} {{- $fullName := include "meet.celeryBackend.fullname" . -}}
{{- $component := "celery-backend" -}} {{- $component := "celery-backend" -}}
apiVersion: apps/v1 apiVersion: apps/v1
@@ -50,6 +51,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.celerySummarize.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -46,6 +46,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.celerySummaryBackend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -1,4 +1,5 @@
{{- $sharedEnvVars := default (dict) .Values.celeryTranscribe.envVars -}} {{- $sharedEnvVars := default (dict) .Values.celeryTranscribe.envVars -}}
{{- $sharedEnvFrom := default (list) .Values.celeryTranscribe.envFrom -}}
{{- $component := "celery-transcribe" -}} {{- $component := "celery-transcribe" -}}
{{- range $idx, $instance := .Values.celeryTranscribe.instances }} {{- range $idx, $instance := .Values.celeryTranscribe.instances }}
@@ -6,6 +7,8 @@
{{- $fullName := printf "%s-%s" (include "meet.celeryTranscribe.fullname" $) $instance.name }} {{- $fullName := printf "%s-%s" (include "meet.celeryTranscribe.fullname" $) $instance.name }}
{{- $extraInstanceEnvVars := default (dict) $instance.extraEnvVars -}} {{- $extraInstanceEnvVars := default (dict) $instance.extraEnvVars -}}
{{- $mergedInstanceEnvVars := merge $extraInstanceEnvVars $sharedEnvVars -}} {{- $mergedInstanceEnvVars := merge $extraInstanceEnvVars $sharedEnvVars -}}
{{- $extraInstanceEnvFrom := default (list) $instance.extraEnvFrom -}}
{{- $envFrom := concat $sharedEnvFrom $extraInstanceEnvFrom -}}
{{- $fakeInstanceObjectForEnvHelper := dict "envVars" $mergedInstanceEnvVars -}} {{- $fakeInstanceObjectForEnvHelper := dict "envVars" $mergedInstanceEnvVars -}}
{{- $envVars := include "meet.common.env" (list . $fakeInstanceObjectForEnvHelper) -}} {{- $envVars := include "meet.common.env" (list . $fakeInstanceObjectForEnvHelper) -}}
apiVersion: apps/v1 apiVersion: apps/v1
@@ -60,6 +63,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.frontend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
@@ -50,6 +50,10 @@ spec:
args: args:
{{- toYaml . | nindent 12 }} {{- toYaml . | nindent 12 }}
{{- end }} {{- end }}
{{- with .Values.summary.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env: env:
{{- if $envVars }} {{- if $envVars }}
{{- $envVars | indent 12 }} {{- $envVars | indent 12 }}
+29
View File
@@ -232,6 +232,9 @@ backend:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param backend.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param backend.podAnnotations Annotations to add to the backend Pod ## @param backend.podAnnotations Annotations to add to the backend Pod
podAnnotations: {} podAnnotations: {}
@@ -433,6 +436,9 @@ frontend:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param frontend.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param frontend.podAnnotations Annotations to add to the frontend Pod ## @param frontend.podAnnotations Annotations to add to the frontend Pod
podAnnotations: {} podAnnotations: {}
@@ -624,6 +630,9 @@ summary:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param summary.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param summary.podAnnotations Annotations to add to the summary Pod ## @param summary.podAnnotations Annotations to add to the summary Pod
podAnnotations: {} podAnnotations: {}
@@ -751,6 +760,9 @@ celeryBackend:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param celeryBackend.envFrom Import additional environment variables from ConfigMaps or Secrets
envFrom: []
## @param celeryBackend.podAnnotations Annotations to add to the celeryBackend Pod ## @param celeryBackend.podAnnotations Annotations to add to the celeryBackend Pod
podAnnotations: {} podAnnotations: {}
@@ -863,6 +875,9 @@ celeryTranscribe:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param celeryTranscribe.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param celeryTranscribe.podAnnotations Annotations to add to the celeryTranscribe Pod ## @param celeryTranscribe.podAnnotations Annotations to add to the celeryTranscribe Pod
podAnnotations: {} podAnnotations: {}
@@ -923,6 +938,7 @@ celeryTranscribe:
## @extra celeryTranscribe.instances[].name Unique name suffix for the instance (used in the Deployment name and pod labels) ## @extra celeryTranscribe.instances[].name Unique name suffix for the instance (used in the Deployment name and pod labels)
## @extra celeryTranscribe.instances[].replicas Override the number of replicas for this specific instance ## @extra celeryTranscribe.instances[].replicas Override the number of replicas for this specific instance
## @extra celeryTranscribe.instances[].extraEnvVars Additional environment variables for this specific instance (same structure as envVars) ## @extra celeryTranscribe.instances[].extraEnvVars Additional environment variables for this specific instance (same structure as envVars)
## @extra celeryTranscribe.instances[].extraEnvFrom Additional ConfigMap or Secret environment sources for this specific instance
## @extra celeryTranscribe.instances[].command Override the container command for this specific instance ## @extra celeryTranscribe.instances[].command Override the container command for this specific instance
## @extra celeryTranscribe.instances[].args Override the container args for this specific instance ## @extra celeryTranscribe.instances[].args Override the container args for this specific instance
## @extra celeryTranscribe.instances[].resources Override resource requirements for this specific instance ## @extra celeryTranscribe.instances[].resources Override resource requirements for this specific instance
@@ -933,6 +949,7 @@ celeryTranscribe:
instances: instances:
- name: default - name: default
extraEnvVars: {} extraEnvVars: {}
extraEnvFrom: []
## @section celerySummarize ## @section celerySummarize
@@ -990,6 +1007,9 @@ celerySummarize:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param celerySummarize.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param celerySummarize.podAnnotations Annotations to add to the celerySummarize Pod ## @param celerySummarize.podAnnotations Annotations to add to the celerySummarize Pod
podAnnotations: {} podAnnotations: {}
@@ -1099,6 +1119,9 @@ celerySummaryBackend:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param celerySummaryBackend.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param celerySummaryBackend.podAnnotations Annotations to add to the celerySummaryBackend Pod ## @param celerySummaryBackend.podAnnotations Annotations to add to the celerySummaryBackend Pod
podAnnotations: {} podAnnotations: {}
@@ -1206,6 +1229,9 @@ agentMetadata:
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
## @param agentMetadata.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param agentMetadata.podAnnotations Annotations to add to the agentMetadata Pod ## @param agentMetadata.podAnnotations Annotations to add to the agentMetadata Pod
podAnnotations: {} podAnnotations: {}
@@ -1297,6 +1323,9 @@ agentSubtitles:
# are NOT supported by the LiveKit Deepgram plugin in streaming mode. # are NOT supported by the LiveKit Deepgram plugin in streaming mode.
# Use language="multi" for automatic multilingual support (10 languages). # Use language="multi" for automatic multilingual support (10 languages).
## @param agentSubtitles.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param agentSubtitles.podAnnotations Annotations to add to the agentSubtitles Pod ## @param agentSubtitles.podAnnotations Annotations to add to the agentSubtitles Pod
podAnnotations: {} podAnnotations: {}