Compare commits

...

63 Commits

Author SHA1 Message Date
briquet ff662a1c8e 🔒️(backend) upgrade base image to python:3.13.15-alpine3.24
Fixes an XML injection attack in libexpat (CVE-2026-93990)
2026-09-24 19:52:33 +02:00
lebaudantoine 8d980192c8 🚸(frontend) inform user that recording waits until a track is published
When a user starts a recording or a transcription while no track is
published yet, the recording stays in a "starting" state until an
appropriate track is available.

Show an explicit message on start explaining that the recording
will remain in "starting" state until a track of the required type
is published. The expected track type depends on the recording
type (audio-only vs. audio + video).

This situation was generating a lot of support requests, with users
asking why the recording did not actually start.
2026-09-24 00:24:40 +02:00
Ovgodd de1f7158f5 📝(changelog) shorten the reception resolution entry
The changelog check fails at 80 columns. Dropping "the" keeps
the reception resolution entry under that limit.
2026-09-22 16:21:00 +02:00
Ovgodd 6e17c6533c ♿️(frontend) use i18n strings for screen share wheel zoom shortcuts
Use i18n strings for displaying screen share zoom shortcuts in the UI controls.
2026-09-22 16:21:00 +02:00
Ovgodd 27e32c0370 ♿️(frontend) add keyboard navigation to screen share zoom toolbar
Arrows move between controls instead of panning, w/ en/fr/nl/de hint update.
2026-09-22 16:21:00 +02:00
Cyril 6d4403d4fa ♻️(frontend) refactor screen share zoom pan with useMove
use react-aria useMove for pan, zoom/pan refs for DOM updates,
2026-09-22 16:21:00 +02:00
Cyril 37ae308825 ♿️(frontend) add wheel zoom shortcut hints to screen share controls
Show Ctrl/Cmd+scroll zoom shortcut in tooltips, aria, SR hints, w/ en/fr/nl/de.
2026-09-22 16:21:00 +02:00
Cyril 16fd2dc4e8 💄(frontend) improve screen share zoom toolbar sizing and containment
Slightly enlarge toolbar controls while clipping hover states
inside the pill, so buttons no longer overflow the bar.
2026-09-22 16:21:00 +02:00
Cyril 9791a8a3b2 💄(frontend) use distinct expand/collapse icons for fullscreen actions
Replace fullscreen icons with expand-diagonal-line and collapse-diagonal-line
2026-09-22 16:21:00 +02:00
Cyril 5b0dece79b 🌐(frontend) add i18n keys for screen share zoom controls
English and French labels, SR announcements and pan navigation hint.
2026-09-22 16:21:00 +02:00
Cyril 96135a0263 ✨(frontend) add zoomable screen share video component
Wraps VideoTrack with zoom/pan, keyboard nav and screen reader announcements.
2026-09-22 16:21:00 +02:00
Cyril ce2e2a4d64 ✨(frontend) add ScreenShareZoomControls toolbar component
Bottom-right toolbar with zoom, fit-to-window and fullscreen buttons.
2026-09-22 16:21:00 +02:00
Cyril e5dc9c2f15 ✨(frontend) add useScreenShareZoom hook for zoom and pan
Manages zoom level, pan offset, wheel zoom, drag-to-pan and keyboard panBy.
2026-09-22 16:21:00 +02:00
kaelvar e97eab9b5e 🐛(frontend) apply the saved reception resolution when joining a meeting
`VideoResolutionSubscription` applied the saved reception resolution on
`RoomEvent.TrackPublished`. livekit-client does not raise that event for cameras
that were already sending when the local participant joined, so a user who had
chosen Low definition still received High definition from everyone already in
the meeting, and Low definition only from whoever joined after them. Nothing in
the UI showed the discrepancy: the setting kept displaying Low definition.

Apply the preference to the publications we already know about when the effect
runs, and keep listening on `TrackPublished` — which stays the earliest point to
cap a camera that starts after us — plus `TrackSubscribed`, which is the first
event raised for the cameras that were already sending.

That initial pass also covers a change of preference mid-call, which
`VideoTab.updateExistingRemoteVideoQuality` was doing separately. Removed, it is
now the same code path for joining and for changing the setting.

The three entry points overlap on purpose; the `publication.videoQuality` guard
makes the repeats free. It reads as High definition when nothing was ever
requested, so the default case costs no signal round trip either.

Fixes #1606.
2026-09-22 15:07:47 +02:00
lebaudantoine 436b3dc9df 🔖(helm) release chart 0.0.28 2026-09-22 13:43:04 +02:00
briquet 6ea2a85810 🚑️(summary) serve health endpoints with the dockerflow router
The dockerflow package ships with a FastAPI router to serve healthcheck
endpoints. A specific Redis test was activated to check the broker.
2026-09-22 12:00:20 +02:00
briquet 3d1ea88e8f ✅(backend) test the dockerflow health endpoints
Test kubernetes probes liveness/readiness endpoints
2026-09-22 12:00:20 +02:00
briquet beb74af574 🔊(backend) change the dockerflow logger level to WARNING
We want the liveness/readiness failure reason to show explicitly in logs
2026-09-22 12:00:20 +02:00
briquet c785b4a627 ♻️(backend) serve the dockerflow views early in the middleware stack
`DockerflowMiddleware` serves the endpoints `/__heartbeat__`, `/__lbheartbeat__
that we use for the kubernetes probes. Sitting at the bottom of
MIDDLEWARE, every Kubernetes probe traversed all middlewares  which is not
efficient.
2026-09-22 12:00:20 +02:00
briquet a9a4246abb 🐛(helm) swap liveness and readiness probes
The backend and summary probes had the two Dockerflow endpoints the wrong way
round. `/__lbheartbeat__` returns an unconditional 200 as soon as the server is
up and touches no dependency, while `/__heartbeat__` runs the Dockerflow checks
and answers 500 when one of them errors.

Wired as they were, a database error made `/__heartbeat__` fail on every
backend pod at once, restarting them all. Since a restart cannot fix a
database outage, it's better to use these check on the readiness probe
and start routing traffic when the database is reachable.

- Probe liveness on `/__lbheartbeat__` and readiness on `/__heartbeat__`
- Add a startup probe on `/__lbheartbeat__`, polled every 5s with a
  `failureThreshold` of 12, leaving the pod a minute to boot
- Drop `initialDelaySeconds` from liveness and readiness, now that the startup
  probe holds them off until the server answers
- Set `timeoutSeconds` to 5s on every probe, up from the 1s Kubernetes default
- Set the readiness `failureThreshold` to 3
2026-09-22 12:00:20 +02:00
briquet 3cf7f60eaa 🐛(helm) render periodSeconds and failureThreshold on probes
The `meet.probes.abstract` helper was missing `periodSeconds` block which means
Kubernetes fell back to its 10s default instead of the chart value.
It's now possible to configure the  `failureThreshold` and `successThreshold`.
2026-09-22 12:00:20 +02:00
snyk-bot bf215f1513 ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
Snyk has created this PR to upgrade i18next from 26.4.0 to 26.4.1.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-22 10:43:55 +02:00
lebaudantoine 75836fc817 ⬆️(devx) update the MinIO image on the Tilt stack
Bump the MinIO image used by the Tilt dev stack to a more recent
version, since the previous public image we relied on was removed.
2026-09-19 20:54:42 +02:00
briquet 1affe65b4a 🔧(dev) configure bureautix proxy for image builds
Builds through the Docker API of the Podman service receive none of the
proxy variables in their RUN steps and fail systematically because of
the proxy rejection. Plain HTTP connections are also rejected by the
proxy with a HTTP 405 method error.

- Passes http_proxy, https_proxy and no_proxy from the shell as build args
- Make the Debian mirror of the agents image a build argument and
  override it for bureautix to force https usage
2026-09-18 16:10:31 +02:00
briquet c34ecbd3ef 🔧(dev) remove the unused bin/compose wrapper
Nothing in the repository nor the CI calls it
2026-09-18 15:17:58 +02:00
briquet 78960d9769 🔧(dev) use a dedicated folder for docker compose overrides
Move extra compose files to docker/compose.d folder
2026-09-18 15:17:51 +02:00
briquet 41d07d36ee 🔧(dev) use port 8081 for keycloak admin on bureautix workstations
The Bureautix workstation proxy listens on 8080, which collides with
Keycloak's published admin port.
2026-09-18 15:17:44 +02:00
Briquet f7386e1741 🔧(dev) add a devenv shell for nix-based workstations
Add the minimal requirements to build and run the project locally on NixOS

- `devenv update` update devenv using NixOS 26.05 stable repositories
- `devenv shell` activates the devenv
- `devenv --profile <profile>` shell uses additional packages when
  activated (profile=agent|summary|k8s)
2026-09-18 15:17:36 +02:00
briquet f1da04eb27 🔧(podman) pin the livekit rtc section for local usage
Pin the LiveKit rtc section: the browser reaches the server through
podman's published ports on loopback while egress and the agents reach
it over the podman network, and those two have no address in common.

`advertise_internal_ip` keeps the container's own interface address as a
host candidate alongside the node_ip one, so LiveKit offers both and ICE
picks whichever works. Without it egress only ever sees 127.0.0.1, which
is the egress container itself, and its peer connection timeouts

`use_external_ip` is turned off since it would advertise the STUN-discovered
public IP, which no local peer can hairpin to.
2026-09-18 15:17:29 +02:00
briquet 2970420b84 🔧(podman) make the dev stack work with rootless podman
Rootless podman maps container UID 0 to the host user and every other
container UID to a subuid that owns nothing in the worktree, so the usual
DOCKER_USER=$(id -u):$(id -g) makes every bind mount effectively
read-only.

Add a compose.podman.yml to override the compose.yml and set
`userns_mode: keep-id` in order to map the host user to the same UID and UID
inside the container. The merge of the docker compose file is now done with
the COMPOSE_FILE environment variable
2026-09-18 15:17:21 +02:00
tanguy chenier 771f58c0aa 🐛(frontend) play the waiting room notification sound on every arrival
The waiting room has its own sound in notifications.mp3, and nothing could play
it: the sprite is named "waiting" while triggerNotificationSound passes a
NotificationType, and howler returns without playing when the sprite id is
unknown. ParticipantWaiting was also absent from the sound settings, so the
check on the store would have refused it first. The toast borrowed the
participant joined sound instead.

The sound was tied to the waiting list going from empty to non empty, so a
second person arriving while someone was still waiting was silent, which is the
case the issue describes.

Name the sprite after the notification type, register the type in the settings,
and sound every arrival, detected on the participant ids so that an admission
and an arrival between two refreshes do not cancel each other out.

closes #1705
2026-09-17 17:06:06 +02:00
briquet b159b20695 🐛(backend) read the Sentry release from pyproject.toml
get_release() read the version from a version.json file  but nothing generates
it during the CI Docker image build, therefore the release reported to Sentry
was always "NA".

Read the version from pyproject.toml instead, which is bumped at each
release and copied into the image.
2026-09-16 15:54:40 +02:00
leo 226d004838 ✅(agents) fix subtitle test
Result of test for display of subtitles was depending on local
env config, potentially failing. Fix this by overriding settings.
2026-09-16 14:20:44 +02:00
lebaudantoine b723b7bb62 🐛(frontend) fix file permissions in the Docker image
Incorrect file permissions in the frontend Docker image caused
problems when running the project, and were surfaced by @briquet
while setting it up with Podman.

Adjust the ownership and permissions applied during the build so
the image works cleanly under Docker and Podman alike.
2026-09-15 00:01:22 +02:00
lebaudantoine cb36df9104 🔧(devx) pull the MinIO image from quay.io
The quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables.
2026-09-14 20:38:44 +02:00
snyk-bot d85123d0c5 ⬆(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
Snyk has created this PR to upgrade humanize-duration from 3.33.2 to 3.34.1.

See this package in npm:
humanize-duration

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 15:40:56 +02:00
snyk-bot b2abf814fa ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
Snyk has created this PR to upgrade i18next from 26.3.6 to 26.4.0.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 09:10:04 +02:00
Miguel Victoria cfdf1c2f92 ✨(backend) add default video codec on apiConfig struct
Co-authored-by: David <60177543+davd-gzl@users.noreply.github.com>
2026-09-14 09:03:05 +02:00
Michel-Marie Maudet 4139f542d3 🔧(ci) pull the MinIO image from quay.io
Docker Hub now denies anonymous pulls of minio/minio (pull access
denied), which fails the test-back job for every pull request. The
quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables, and the container lookup in the Configure
MinIO step still matches the image name.

thx @mmaudet
2026-09-14 07:43:02 +02:00
snyk-bot eda66640df ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
Snyk has created this PR to upgrade posthog-js from 1.414.0 to 1.418.10.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 07:38:10 +02:00
snyk-bot 3fa05ea785 ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
Snyk has created this PR to upgrade react-i18next from 17.0.10 to 17.0.12.

See this package in npm:
react-i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 07:03:25 +02:00
lebaudantoine 30b68052e1 ⚡️(frontend) defer loading the Crisp script until idle
Load the Crisp JavaScript module only once the frontend is idle,
instead of during the initial page load.

Keeps the critical path lighter and prevents Crisp from competing
with the app's own bootstrap for network and CPU on slow devices.
2026-09-13 00:03:55 +02:00
lebaudantoine 04fd79b56b 🔒️(backend) reject inactive users in resource server backend
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.

Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
2026-09-10 11:10:53 +02:00
leo e336122cfa 💬(frontend) clarify video recording wording
Video recording from transcription panel did not explicitly
mention video, leading to confusion from some users. Make
wording more explicit.
2026-09-09 20:03:36 +02:00
lebaudantoine 172dc70649 ✨(backend) allow configuring trace sampling
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.

Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
2026-09-09 20:02:57 +02:00
lebaudantoine e0ab7f191f 📈(frontend) include LiveKit SIDs in the connection analytics event
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.

Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
2026-09-09 13:38:44 +02:00
lebaudantoine 455b315dbb 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.

Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
2026-09-09 12:09:13 +02:00
lebaudantoine 3089b03062 🔇(backend) silence noisy request summary info logs
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.

Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine 60febb3b57 🔇(backend) silence expected 401 warnings on /me
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.

These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine bf76ab1ddf 🔒️(backend) enforce display name setting on rename API
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.

Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.

Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
2026-09-08 01:26:27 +02:00
lebaudantoine 7565ede0a7 🔖(minor) bump release to 1.31.0 2026-09-08 00:45:01 +02:00
lebaudantoine 1a15e9f44e ✨(frontend) align feedback buttons with rating card
Match the button row width to the rating card (100%, max 410px) and
make both buttons share it equally so their edges line up with the card.
2026-09-07 23:55:05 +02:00
lebaudantoine 7838d8acfe 🐛(frontend) refetch waiting participants when the lobby becomes disabled
When the lobby is disabled mid-meeting (e.g. the room is switched to
public), the waiting participants list stopped being refetched, so
the previously cached list stayed visible with stale data.

Trigger a refetch in that case as well, so the list is cleared and
the moderator UI no longer shows waiting participants for a lobby
that is no longer active.
2026-09-07 23:30:27 +02:00
lebaudantoine 3bb388b937 ✨(backend) sort waiting participants by their arrival time
Highlighted by a suggestion from @florent, the waiting participant
list was not sorted, so moderators could see participants in an
arbitrary order.

Add an explicit `entered_at` attribute on each waiting participant,
so the list can be sorted by arrival time. Participants are now
shown in a stable order of arrival, both across polls and across
moderators.
2026-09-07 23:30:27 +02:00
lebaudantoine e1cc8105db 💄(frontend) position the login hint dynamically next to the button
Compute the position of the login hint at render time so it is
always displayed close to the login button, regardless of the
button's placement or the current viewport size.
2026-09-07 20:12:57 +02:00
lebaudantoine 7844dfcc12 📈(frontend) track missing lobby participant on accept/reject
When a moderator accepts or rejects a lobby entry that no longer
exists, emit a tracking event so we can measure how often it
happens.

This signal will help tune the lobby polling interval: too many
"not found" events means the moderator side is working from a stale
list. Keep raising the error to the client on top of tracking it,
so the frontend still surfaces the issue (its current handling of
this case is still incomplete).
2026-09-07 20:12:57 +02:00
lebaudantoine ef71003721 🔊(backend) log request duration in Gunicorn workers
Include the time taken by each request in the Gunicorn worker
access logs, so we can spot slow endpoints and correlate latency
patterns directly from the logs.
2026-09-07 20:12:57 +02:00
lebaudantoine f74d23c57e ⚡️(backend) refactor presence cache to bound key lookups per room
The previous presence cache lookup keyed off a scan over the whole
cache, so its cost was O(db_size) rather than O(room_size).
Combined with the recent switch to cursor-based `SCAN` at an
inappropriate page size, this caused a lot of Redis round-trips and
noticeably slowed down the backend pods under load.

Refactor the presence cache to keep a per-room set of all its
participant keys. Lookups now iterate that set instead of scanning
the whole database.

Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
2026-09-07 20:12:57 +02:00
lebaudantoine acedb21045 ⚡️(backend) refactor lobby storage to bound key lookups per room
The previous lobby lookup keyed off a scan over the whole cache, so
its cost was O(db_size) rather than O(room_size). Combined with the
recent switch to cursor-based `SCAN` at an inappropriate page size,
this caused a lot of Redis round-trips and noticeably slowed down
the backend pods under load.

Refactor the lobby storage to keep a per-room set of all its lobby
keys. Lookups now iterate that set instead of scanning the whole
database:

* Membership in the set acts as a memory of who is supposedly in
  the lobby for a given room.
* Individual keys are then read to check who is actually still
  waiting or accepted.

Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
2026-09-07 20:12:57 +02:00
lebaudantoine 67e7d382e3 ⚡️(frontend) add trailing slash on the /me endpoint call
The `/me` endpoint was called without a trailing slash, so every
request was going through a 301 redirect before hitting the actual
endpoint.

This endpoint is called by every user at least once per session, so
based on the logs, avoiding the redirect should cut the volume of
requests hitting it by around 10%.
2026-09-07 20:12:57 +02:00
lebaudantoine 164ac8d948 ⚡️(frontend) increase lobby polling interval on both sides
Increase the polling interval used by the lobby feature, on both the
waiting participant side and the moderator side.

The goal is to reduce the volume of requests the lobby generates,
trading a bit of data freshness for better performance.

It will de facto reduce pressure on the backend.

We will observe the impact in production, and revisit these
intervals if the delays turn out to be too aggressive.
2026-09-07 20:12:57 +02:00
lebaudantoine e3deb37fbe 🔒️(frontend) upgrade base image to 1.30.4-alpine3.24
Bump the frontend base image to `1.30.4-alpine3.24`, which picks up
fixes for the CVEs listed below and lets us drop the individual
dependency pins that were only there to address earlier known CVEs.

Address the following HIGH severity CVEs in libuuid / util-linux,
reported by Trivy. Bumping to 2.41.6-r1 (bundled in the new base
image) covers all of them:

* CVE-2026-53612 — TOCTOU in mount post-mount ownership/mode
  changes.
* CVE-2026-53613 — TOCTOU in mount via ancestor directory swap.
* CVE-2026-53614 — SUID mount(8) nosuid/noexec bypass via
  LIBMOUNT_FORCE_MOUNT2.
* CVE-2026-76642 — failed external mount helper still runs
  privileged X-mount post-hooks.
* CVE-2026-78408 — nsenter --join-cgroup leaks root cgroup
  migration authority (fixed in 2.41.6-r1).
* CVE-2026-78410 — restricted bind mounts do not pin the source,
  allowing X-mount.owner/group/mode escalation.
2026-09-07 16:40:03 +02:00
lebaudantoine 33929324d0 🐛(frontend) keep feedback buttons on one line for fr/es/en
A minor layout regression appeared when switching to the Marianne
font: the feedback buttons wrapped onto two lines instead of
staying on one.

Adjust the layout so the buttons stay on a single line regardless
of the font in use.
2026-09-07 16:40:03 +02:00
110 changed files with 2778 additions and 485 deletions
+2 -2
View File
@@ -252,13 +252,13 @@ jobs:
- name: Start MinIO
run: |
docker pull minio/minio
docker pull quay.io/minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
quay.io/minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
+3
View File
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
# NixOS
.devenv
+48
View File
@@ -10,13 +10,55 @@ and this project adheres to
### Added
- ✨(backend) make the LiveKit default video codec configurable
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
### Changed
- 🔊(backend) pin the dockerflow logger level to WARNING
- 🚑️(summary) serve health endpoints with the dockerflow router
- ♻️(backend) serve the dockerflow views early in the middleware stack
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
- 🔖(helm) release chart 0.0.28
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
- 🚸(frontend) inform user that recording waits until a track is published
- 🔒(backend) upgrade base image to python:3.13.15-alpine3.24
## [1.31.0] - 2026-09-08
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
- ✨(backend) update a room's attributes from the external API
- 🔊(backend) log request duration in Gunicorn workers
- 📈(frontend) track missing lobby participant on accept/reject
- ✨(backend) sort waiting participants by their arrival time
### Changed
- ⬆️(dev) pin LiveKit server to v1.13.6
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
### Fixed
@@ -24,6 +66,12 @@ and this project adheres to
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
- ⚡️(frontend) increase lobby polling interval on both sides
- ⚡️(frontend) add trailing slash on the /me endpoint call
- ⚡️(backend) refactor lobby storage to bound key lookups per room
- ⚡️(backend) refactor presence cache to bound key lookups per room
- 💄(frontend) position the login hint dynamically next to the button
## [1.30.0] - 2026-09-01
+1 -1
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.5-alpine3.21 AS base
FROM python:3.13.15-alpine3.24 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
+1 -1
View File
@@ -292,7 +292,7 @@ shell: ## connect to database shell
# -- Database
dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
@$(COMPOSE_EXEC_APP) python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
+1 -2
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
COMPOSE_FILE="${REPO_DIR}/compose.yml"
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
COMPOSE_PROJECT="meet"
@@ -42,7 +42,6 @@ function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"
}
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_docker_compose "$@"
+2 -2
View File
@@ -17,7 +17,7 @@ services:
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
image: quay.io/minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
@@ -35,7 +35,7 @@ services:
- ./data/media:/data
createbuckets:
image: minio/mc
image: quay.io/minio/mc
depends_on:
minio:
condition: service_healthy
+47
View File
@@ -0,0 +1,47 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1778705847,
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
"ref": "refs/tags/v2.1.2",
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
"revCount": 6569,
"type": "git",
"url": "https://github.com/cachix/devenv"
},
"original": {
"dir": "src/modules",
"ref": "refs/tags/v2.1.2",
"type": "git",
"url": "https://github.com/cachix/devenv"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1789542786,
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
"ref": "nixos-26.05",
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
},
"original": {
"ref": "nixos-26.05",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+265
View File
@@ -0,0 +1,265 @@
# =============================================================================
# devenv.nix — La Suite Meet ("Visio") developer environment
# =============================================================================
{
pkgs,
lib,
config,
...
}:
let
python = pkgs.python313;
nodejs = pkgs.nodejs_22;
backendDir = "src/backend";
agentsDir = "src/agents";
summaryDir = "src/summary";
frontendDir = "src/frontend";
readDotEnv =
file:
let
lines = lib.splitString "\n" (builtins.readFile file);
unquote =
v:
let
len = builtins.stringLength v;
in
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
builtins.substring 1 (len - 2) v
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
builtins.substring 1 (len - 2) v
else
v;
parseLine =
line:
let
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
in
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
in
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
# Reuse existing .env
dotEnv =
(readDotEnv ./env.d/development/common.dist)
// (readDotEnv ./env.d/development/postgresql.dist);
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
in
{
options.meet = {
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
};
config = {
# Profile can be activated with devenv --profile <profile> shell
profiles = {
agents.module = {
meet.agents.enable = true;
};
summary.module = {
meet.summary.enable = true;
};
k8s.module = {
meet.k8s.enable = true;
};
};
languages.python = {
enable = true;
package = python;
directory = backendDir;
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
libraries = [
"${config.devenv.dotfile}/profile"
pkgs.file
pkgs.zlib
pkgs.libffi
pkgs.openssl
];
uv.enable = true;
uv.sync.enable = false;
venv.enable = false;
lsp.enable = true;
};
languages.javascript = {
enable = true;
package = nodejs;
directory = frontendDir;
npm.enable = true;
yarn.enable = true;
corepack.enable = false;
};
languages.typescript.enable = false;
languages.nix.enable = true;
packages =
with pkgs;
[
gnumake
file
shared-mime-info
gettext
postgresql_16
git
curl
jq
podman
podman-compose
docker-client
]
# -- LiveKit agents
++ lib.optionals config.meet.agents.enable [
glib
portaudio
livekit-cli
]
# -- summary service
++ lib.optionals config.meet.summary.enable [
redis
]
# -- Kubernetes tools
++ lib.optionals config.meet.k8s.enable [
kubectl
kubernetes-helm
helmfile
tilt
kind
mkcert
];
env = sharedEnv // {
UV_LINK_MODE = "copy";
PYTHONDONTWRITEBYTECODE = "1";
PYTHONUNBUFFERED = "1";
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
COMPOSE_PROJECT_NAME = "meet";
DJANGO_DATA_DIR = "${config.devenv.root}/data";
# Database / Pgsql
DB_HOST = "127.0.0.1";
DB_PORT = "15432";
PGHOST = "127.0.0.1";
PGPORT = "15432";
PGDATABASE = sharedEnv.DB_NAME;
PGUSER = sharedEnv.DB_USER;
PGPASSWORD = sharedEnv.DB_PASSWORD;
REDIS_URL = "redis://127.0.0.1:6379/1";
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
# S3 / MinIO
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
# OIDC
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
# summary service
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
SUMMARY_SERVICE_VERSION = "2";
# Mail
DJANGO_EMAIL_HOST = "127.0.0.1";
};
scripts = {
meet-venv = {
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
exec = ''
set -euo pipefail
cd "$DEVENV_ROOT"
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
( cd "${backendDir}" && uv sync --locked --all-groups )
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
( cd "${agentsDir}" && uv sync --locked --all-extras )
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
( cd "${summaryDir}" && uv sync --locked --all-extras )
echo
echo "Synced the following virtualenvs successfully:"
echo " ${backendDir}/.venv"
echo " ${agentsDir}/.venv"
echo " ${summaryDir}/.venv"
'';
};
};
enterShell = ''
# Make podman socket accessible in order to launch regular docker commands.
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
case "''${MEET_PODMAN_SOCKET:-1}" in
0|false|no|off) ;;
*)
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
unset _rundir
;;
esac
# Compose files to merge
_compose_dir="${config.devenv.root}/docker/compose.d"
_compose_files="${config.devenv.root}/compose.yml"
export DOCKER_USER="$(id -u):$(id -g)"
case "''${DOCKER_HOST:-}" in
*podman*)
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
# Build images with Podman/Buildah rather than BuildKit. `docker
# compose build` otherwise has buildx boot a moby/buildkit container,
# and that container lands in its own network namespace with neither
# the proxy in its environment nor any route to it.
# Buildah has neither problem: base images are resolved by the Podman systemd
# service, which inherits the proxy from its systemd socket activated unit, and
# RUN steps execute in the *host* network namespace
export DOCKER_BUILDKIT=0
export COMPOSE_BAKE=false
;;
esac
# Apply Bureautix override
if [ -n "''${http_proxy:-}" ]; then
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
fi
export COMPOSE_FILE="$_compose_files"
unset _compose_dir _compose_files
# Make binaries accessible
for _d in \
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
do
[ -d "$_d" ] && export PATH="$_d:$PATH"
done
unset _d
'';
};
}
+5
View File
@@ -0,0 +1,5 @@
inputs:
nixpkgs:
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
devenv:
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
+48
View File
@@ -0,0 +1,48 @@
# Bureautix proxy overrides
#
# Builds submitted through the Docker API of the Podman service get none of
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
# otherwise all connections fail during the build.
x-proxy-vars: &proxy-vars
http_proxy: ${http_proxy:-}
https_proxy: ${https_proxy:-}
no_proxy: ${no_proxy:-}
services:
app:
build:
args:
<<: *proxy-vars
app-dev:
build:
args:
<<: *proxy-vars
frontend:
build:
args:
<<: *proxy-vars
metadata-collector-dev:
build:
args:
<<: *proxy-vars
multi-user-transcriber-dev:
build:
args:
<<: *proxy-vars
app-summary-dev:
build:
args:
<<: *proxy-vars
celery-summary-transcribe:
build:
args:
<<: *proxy-vars
celery-summary-summarize:
build:
args:
<<: *proxy-vars
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
+33
View File
@@ -0,0 +1,33 @@
# Rootless Podman override for compose.yml.
#
# Rootless Podman maps container UID 0 to the host user and every other
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
# subuid and make every bind mount effectively read-only.
#
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
# container instead, so DOCKER_USER keeps its Docker value and files written
# through a bind mount are owned by the host user on both sides.
#
# Only the services that mount the worktree and run as DOCKER_USER are listed.
x-keep-id: &keep-id
userns_mode: keep-id
services:
app-dev:
<<: *keep-id
celery-dev:
<<: *keep-id
minio:
<<: *keep-id
node:
<<: *keep-id
crowdin:
<<: *keep-id
metadata-collector-dev:
<<: *keep-id
multi-user-transcriber-dev:
<<: *keep-id
app-summary-dev:
<<: *keep-id
+3 -11
View File
@@ -54,19 +54,11 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl
RUN apk del curl
USER nginx
USER nginx
@@ -14,3 +14,4 @@ accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
@@ -21,3 +21,10 @@ turn:
- 192.168.0.0/16
- 172.16.0.0/12
rtc:
node_ip: 127.0.0.1
advertise_internal_ip: true
udp_port: 7882
tcp_port: 7881
use_external_ip: false
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.3.6",
"i18next": "26.4.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9367,9 +9367,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"version": "26.4.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.3.6",
"i18next": "26.4.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+3 -2
View File
@@ -1,7 +1,8 @@
FROM python:3.14.6-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.29.0"
version = "1.31.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.29.0"
version = "1.31.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
+1
View File
@@ -71,6 +71,7 @@ def get_frontend_configuration(request):
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
},
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
+9
View File
@@ -909,6 +909,15 @@ class RoomViewSet(
"""Rename the current participant in the room."""
room = self.get_object()
if (
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
and request.user.is_authenticated
):
return drf_response.Response(
{"error": "Authenticated participants cannot edit their display name"},
status=drf_status.HTTP_403_FORBIDDEN,
)
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
+25
View File
@@ -0,0 +1,25 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
+8 -10
View File
@@ -52,12 +52,6 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete."""
@@ -74,6 +68,7 @@ class LiveKitWebhookEventType(Enum):
# Participant events
PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events
TRACK_PUBLISHED = "track_published"
@@ -153,10 +148,13 @@ class LiveKitEventsService:
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
except ValueError:
logger.warning(
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
data.event,
room_name,
)
return
# Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value)
+84 -17
View File
@@ -4,11 +4,12 @@ import logging
import uuid
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Tuple
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from core import models, utils
@@ -46,6 +47,7 @@ class LobbyParticipant:
username: str
color: str
id: str
entered_at: str
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
@@ -54,6 +56,7 @@ class LobbyParticipant:
"username": self.username,
"id": self.id,
"color": self.color,
"entered_at": self.entered_at,
}
@classmethod
@@ -68,6 +71,7 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
entered_at=data["entered_at"],
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -86,6 +90,47 @@ class LobbyService:
"""Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_index_key(room_id: UUID) -> str:
"""Raw Redis key of the per-room participant index (a native SET)."""
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_add(self, room_id: UUID, participant_id: str) -> None:
"""Record a participant id in the room index."""
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, participant_id)
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
pipe.execute()
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
"""All participant ids currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return frozenset(
member.decode() if isinstance(member, bytes) else member
for member in members
)
def _index_touch(self, room_id: UUID) -> None:
"""Re-arm the room index backstop TTL."""
self._redis().expire(
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
)
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
"""Drop participant ids from the room index."""
if participant_ids:
self._redis().srem(self._get_index_key(room_id), *participant_ids)
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
@@ -162,6 +207,7 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -209,15 +255,12 @@ class LobbyService:
cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
)
self._index_touch(room_id)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room
participants of the new entry request.
"""
"""Add participant to waiting lobby."""
color = utils.generate_color(participant_id)
@@ -226,6 +269,7 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
entered_at=timezone.now().isoformat(),
)
try:
@@ -245,6 +289,7 @@ class LobbyService:
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
self._index_add(room_id, participant_id)
return participant
@@ -266,28 +311,42 @@ class LobbyService:
cache.delete(cache_key)
return None
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
"""List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*")
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
member_ids = self._index_members(room_id)
if not keys:
return []
if not member_ids:
return ()
data = cache.get_many(keys)
keys_by_id = {
participant_id: self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
}
data = cache.get_many(list(keys_by_id.values()))
dead_ids = []
waiting_participants = []
for cache_key, raw_participant in data.items():
for participant_id, cache_key in keys_by_id.items():
raw_participant = data.get(cache_key)
if raw_participant is None:
dead_ids.append(participant_id)
continue
try:
participant = LobbyParticipant.from_dict(raw_participant)
except LobbyParticipantParsingError:
cache.delete(cache_key)
dead_ids.append(participant_id)
continue
if participant.status == LobbyParticipantStatus.WAITING:
waiting_participants.append(participant.to_dict())
return waiting_participants
self._index_remove(room_id, *dead_ids)
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
return tuple(waiting_participants)
def handle_participant_entry(
self,
@@ -341,16 +400,24 @@ class LobbyService:
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
self._index_touch(room_id)
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
)
member_ids = self._index_members(room_id)
if member_ids:
cache.delete_many(
[
self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
]
)
self._redis().delete(self._get_index_key(room_id))
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
self._index_remove(room_id, participant_id)
+48 -21
View File
@@ -1,25 +1,11 @@
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
"""Presence cache."""
from typing import FrozenSet
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@@ -29,24 +15,65 @@ class PresenceCache:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
@staticmethod
def _get_index_key(room_id: UUID | str) -> str:
"""Raw Redis key of the per-room identity index (a native SET).
Built through django-redis' make_key so it lives under the same
KEY_PREFIX/version namespace as the presence entries.
"""
return cache.client.make_key(
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
)
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
"""All identities currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return frozenset(
member.decode() if isinstance(member, bytes) else member
for member in members
)
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id`."""
"""Record that `identity` is in `room_id` and index it for the room."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, identity)
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
pipe.execute()
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
self._redis().srem(self._get_index_key(room_id), identity)
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
"""Forget presence for every participant of a room (on room_finished).
Deletes the indexed entries and the index itself with targeted
commands instead of a full-keyspace pattern scan.
"""
identities = self._index_members(room_id)
if identities:
cache.delete_many(
[self._get_cache_key(room_id, identity) for identity in identities]
)
self._redis().delete(self._get_index_key(room_id))
@@ -9,6 +9,7 @@ from unittest import mock
from django.core.cache import cache
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ... import utils
@@ -24,6 +25,7 @@ pytestmark = pytest.mark.django_db
# Tests for request_entry endpoint
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_anonymous(settings):
"""Anonymous users should be allowed to request entry to a room."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -59,6 +61,7 @@ def test_request_entry_anonymous(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": None,
}
@@ -71,6 +74,7 @@ def test_request_entry_anonymous(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user(settings):
"""Authenticated users should be allowed to request entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -108,6 +112,7 @@ def test_request_entry_authenticated_user(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": None,
}
@@ -120,6 +125,7 @@ def test_request_entry_authenticated_user(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_with_existing_participants(settings):
"""Anonymous users should be allowed to request entry to a room with existing participants."""
# Create a restricted access room
@@ -138,6 +144,7 @@ def test_request_entry_with_existing_participants(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
cache.set(
@@ -147,6 +154,7 @@ def test_request_entry_with_existing_participants(settings):
"username": "user2",
"status": "accepted",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -178,6 +186,7 @@ def test_request_entry_with_existing_participants(settings):
assert response.json() == {
"id": participant_id,
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
@@ -192,6 +201,7 @@ def test_request_entry_with_existing_participants(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_public_room(settings):
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -230,6 +240,7 @@ def test_request_entry_public_room(settings):
assert response.json() == {
"id": "123",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
@@ -240,6 +251,7 @@ def test_request_entry_public_room(settings):
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -282,6 +294,7 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
@@ -292,6 +305,7 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_waiting_participant_public_room(settings):
"""While waiting, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -308,6 +322,7 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -338,6 +353,7 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "accepted",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": {"token": "test-token"},
}
@@ -443,6 +459,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
"status": "waiting",
"username": "foo",
"color": "123",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -578,6 +595,7 @@ def test_list_waiting_participants_success(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
cache.set(
@@ -587,28 +605,35 @@ def test_list_waiting_participants_success(settings):
"username": "user2",
"status": "waiting",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
},
)
lobby_service = LobbyService()
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
]
assert response.json() == {
"participants": [
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
},
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
]
}
def test_list_waiting_participants_empty(settings):
@@ -372,6 +372,67 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
def test_rename_participant_forbidden_when_display_name_edit_disabled(
mock_livekit_client, settings, room, token, name
):
"""
Test rename is rejected for authenticated users when the self-hoster
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"error": "Authenticated participants cannot edit their display name"
}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_allowed_when_display_name_edit_enabled(
mock_livekit_client, settings, room, token
):
"""Test rename still works for authenticated users when the setting is enabled."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
mock_livekit_client, settings, room, anonymous_token
):
"""
Test the setting only restricts authenticated users: anonymous participants
have no account name to fall back on and can still rename themselves.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_success_anonymous(
mock_livekit_client, room, anonymous_token
):
@@ -117,9 +117,11 @@ def test_start_subtitle_invalid_token():
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token):
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
"""Test that subtitle functionality is disabled when feature flag is off."""
settings.ROOM_SUBTITLE_ENABLED = False
room = RoomFactory()
user = UserFactory()
client = APIClient()
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config):
"""Should return 422 for unknown event type."""
"""Should acknowledge (200) an unknown event type rather than reject it."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 422
assert response.json() == {
"status": "error",
}
assert response.status_code == 200
assert response.json() == {"status": "success"}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,7 +16,6 @@ from core.services.livekit_events import (
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
@@ -665,22 +664,27 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
"""Unknown event types are logged and ignored, not rejected.
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "unsupported_event"
mock_data.event = "some_future_event"
mock_receive.return_value = mock_data
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
service.receive(mock_request) # must not raise
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
caplog.text
)
@mock.patch.object(api.WebhookReceiver, "receive")
+169 -22
View File
@@ -2,7 +2,7 @@
Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913
# pylint: disable=W0621,W0613, W0212, R0913, C0302
# ruff: noqa: PLR0913, PLR0917
import uuid
@@ -14,6 +14,7 @@ from django.core.cache import cache
from django.http import HttpResponse
import pytest
from freezegun import freeze_time
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel
@@ -24,7 +25,6 @@ from core.services.lobby import (
LobbyParticipantStatus,
LobbyService,
)
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -56,6 +56,7 @@ def participant_dict():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
@@ -67,6 +68,7 @@ def participant_data():
username="test-username",
id="test-participant-id",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
@@ -78,6 +80,7 @@ def test_lobby_participant_to_dict(participant_data):
assert result["username"] == "test-username"
assert result["id"] == "test-participant-id"
assert result["color"] == "#123456"
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_success(participant_dict):
@@ -88,6 +91,20 @@ def test_lobby_participant_from_dict_success(participant_dict):
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_missing_entered_at():
"""`entered_at` is mandatory; data without it is rejected."""
data = {
"status": "waiting",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(data)
def test_lobby_participant_from_dict_default_status():
@@ -96,6 +113,7 @@ def test_lobby_participant_from_dict_default_status():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant = LobbyParticipant.from_dict(data_without_status)
@@ -121,6 +139,7 @@ def test_lobby_participant_from_dict_invalid_status():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
@@ -265,6 +284,7 @@ def test_request_entry_public_room(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -303,6 +323,7 @@ def test_request_entry_trusted_room(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -345,6 +366,7 @@ def test_request_entry_new_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
mock_enter.return_value = participant_data
@@ -372,6 +394,7 @@ def test_request_entry_waiting_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -400,6 +423,7 @@ def test_request_entry_accepted_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -440,6 +464,7 @@ def test_request_entry_participant_with_role(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -466,18 +491,23 @@ def test_request_entry_participant_with_role(
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
)
lobby_service._index_touch.assert_called_once_with(room.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
@freeze_time("2025-01-01 10:00:00")
def test_enter_success(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
@@ -497,6 +527,7 @@ def test_enter_success(
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -508,13 +539,16 @@ def test_enter_success(
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
mock_index_add.assert_called_once_with(room.id, participant_id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_with_notification_error(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
@@ -541,6 +575,7 @@ def test_enter_with_notification_error(
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_index_add.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
@@ -579,14 +614,15 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
mock_cache.iter_keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._index_members = mock.Mock(return_value=[])
lobby_service._index_remove = mock.Mock()
result = lobby_service.list_waiting_participants(room.id)
assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
assert result == ()
lobby_service._index_members.assert_called_once_with(room.id)
lobby_service._index_remove.assert_not_called()
mock_cache.get_many.assert_not_called()
@@ -595,7 +631,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key]
lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id)
@@ -603,8 +640,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert len(result) == 1
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
lobby_service._index_members.assert_called_once_with(room.id)
lobby_service._index_remove.assert_called_once_with(room.id)
mock_cache.get_many.assert_called_once_with([cache_key])
@@ -620,6 +657,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user1",
"id": "participant1",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant2 = {
@@ -627,9 +665,13 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -639,15 +681,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert len(result) == 2
# Verify both participants are in the result
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
# Most recent entry comes first
assert [p["id"] for p in result] == ["participant2", "participant1"]
assert result[0]["username"] == "user2"
assert result[1]["username"] == "user1"
# Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
lobby_service._index_members.assert_called_once_with(room.id)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -656,12 +698,13 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key]
lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id)
assert result == []
assert result == ()
mock_cache.delete.assert_called_once_with(cache_key)
@@ -677,11 +720,15 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}
corrupted_participant = {"invalid": "data"}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {
cache_key1: corrupted_participant,
cache_key2: valid_participant,
@@ -699,8 +746,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -716,15 +761,20 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"username": "user1",
"id": "participant1",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant2 = {
"status": "accepted",
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -816,10 +866,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
lobby_service._update_participant_status(
room.id,
@@ -833,10 +885,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
)
lobby_service._index_touch.assert_called_once_with(room.id)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -857,6 +911,7 @@ def test_clear_room_cache(settings, lobby_service):
username="participant1",
id="participant1",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
@@ -867,6 +922,7 @@ def test_clear_room_cache(settings, lobby_service):
username="participant2",
id="participant2",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
@@ -877,13 +933,18 @@ def test_clear_room_cache(settings, lobby_service):
username="participant3",
id="participant3",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
for participant_id in ("participant1", "participant2", "participant3"):
lobby_service._index_add(room_id, participant_id)
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
assert lobby_service._index_members(room_id) == frozenset()
def test_clear_room_empty(settings, lobby_service):
@@ -908,12 +969,16 @@ def test_clear_participant_cache(lobby_service):
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
lobby_service._index_add(room_id, participant_id)
assert cache.get(cache_key) is not None
assert participant_id in lobby_service._index_members(room_id)
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
assert participant_id not in lobby_service._index_members(room_id)
def test_clear_participant_cache_nonexistent(lobby_service):
@@ -927,3 +992,85 @@ def test_clear_participant_cache_nonexistent(lobby_service):
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
def test_index_add_members_remove_roundtrip(lobby_service):
"""The room index records, lists and forgets participant ids."""
room_id = uuid.uuid4()
assert lobby_service._index_members(room_id) == frozenset()
lobby_service._index_add(room_id, "participant1")
lobby_service._index_add(room_id, "participant2")
assert sorted(lobby_service._index_members(room_id)) == [
"participant1",
"participant2",
]
# The index carries a backstop TTL so abandoned rooms cannot leak it.
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
lobby_service._index_remove(room_id, "participant1")
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
@mock.patch("core.utils.notify_participants")
def test_enter_registers_participant_in_room_index(
mock_notify, lobby_service, participant_id, username
):
"""Entering the lobby must index the participant id for the room."""
room_id = uuid.uuid4()
lobby_service.enter(room_id, participant_id, username)
assert lobby_service._index_members(room_id) == frozenset([participant_id])
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
"""Indexed ids whose cache entry expired are pruned and not listed."""
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
room_id = uuid.uuid4()
cache.set(
f"test-lobby-prune_{room_id!s}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
timeout=100,
)
lobby_service._index_add(room_id, "participant1")
# participant2 is indexed but its cache entry has expired.
lobby_service._index_add(room_id, "participant2")
result = lobby_service.list_waiting_participants(room_id)
assert [participant["id"] for participant in result] == ["participant1"]
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
"""A lone waiter's polling must keep the room index alive.
Regression test: the index backstop TTL is only armed at enter() time,
so a participant whose rolling WAITING refreshes outlast it would keep
their entry alive while silently vanishing from the moderator list.
Refreshing the waiting status must therefore re-arm the index TTL.
"""
room_id = uuid.uuid4()
lobby_service._index_add(room_id, participant_id)
index_key = lobby_service._get_index_key(room_id)
redis_client = lobby_service._redis()
redis_client.expire(index_key, 10)
assert redis_client.ttl(index_key) <= 10
lobby_service.refresh_waiting_status(room_id, participant_id)
assert redis_client.ttl(index_key) > 10
assert lobby_service._index_members(room_id) == frozenset([participant_id])
@@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room():
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
def test_presence_clear_room_removes_many_entries_and_the_index():
"""clear_room removes every entry of the room through the index — never
a keyspace scan — and leaves other rooms untouched."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
assert presence._index_members(room_id) == frozenset([])
assert presence._index_members(other_room) == frozenset(["user-0"])
+78
View File
@@ -0,0 +1,78 @@
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
from unittest import mock
from django.core import checks
from django.test.utils import override_settings
import pytest
from dockerflow.django.views import django_check_registry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
def test_dockerflow_endpoints_are_anonymous(client, path):
"""Both endpoints answer without authentication."""
response = client.get(path)
assert response.status_code == 200
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
"""The load balancer heartbeat answers 200 without touching the database."""
with django_assert_num_queries(0):
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
"""The probes are served before ALLOWED_HOSTS is enforced."""
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
assert response.status_code == 200
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
"""The heartbeat runs the database, migrations and redis checks."""
with override_settings(DEBUG=True):
response = client.get("/__heartbeat__")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["details"] == {}
assert payload["checks"]["check_database_connected"] == "ok"
assert payload["checks"]["check_migrations_applied"] == "ok"
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
"""A check reporting an error takes the heartbeat down with a 500."""
def failing_check(**kwargs):
return [checks.Error("Could not connect to database", id="health.E001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[failing_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 500
assert response.json()["status"] == "error"
def test_dockerflow_heartbeat_tolerates_warnings(client):
"""A warning in the check should not affect the probes."""
def warning_check(**kwargs):
return [checks.Warning("Unapplied migration", id="health.W001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[warning_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 200
assert response.json()["status"] == "warning"
@@ -0,0 +1,96 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
@@ -0,0 +1,47 @@
"""Unit tests for the get_release settings helper."""
import re
import pytest
from meet.settings import get_release
@pytest.fixture(name="base_dir")
def fixture_empty_base_dir(tmp_path, monkeypatch):
"""Point get_release at an empty directory."""
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
return tmp_path
def test_get_release_reads_project_pyproject():
"""Should return the semantic version of the backend's pyproject.toml."""
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
def test_get_release_reads_pyproject_version(base_dir):
"""Should return the version declared in the [project] table."""
(base_dir / "pyproject.toml").write_text(
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
)
assert get_release() == "1.2.3"
@pytest.mark.usefixtures("base_dir")
def test_get_release_missing_pyproject():
"""Should fall back to "NA" without a pyproject.toml."""
assert get_release() == "NA"
@pytest.mark.parametrize(
"content",
[
'[project]\nname = "meet"\n', # no version
"[tool.uv]\npackage = true\n", # no [project] table
"[project\nversion = ", # malformed TOML
],
)
def test_get_release_unreadable_version(base_dir, content):
"""Should fall back to "NA" without a readable version in pyproject.toml."""
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
assert get_release() == "NA"
@@ -0,0 +1,22 @@
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
import pytest
from meet.settings import VideoCodecValue
@pytest.mark.parametrize(
"raw,expected",
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
)
def test_video_codec_value_normalizes(raw, expected):
"""Whitespace is trimmed and the name is lowercased before it is checked."""
# environ=False keeps __new__ from resolving the value, so the instance survives.
assert VideoCodecValue(environ=False).to_python(raw) == expected
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
def test_video_codec_value_rejects_unsupported(raw):
"""A name outside the accepted list stops the settings module loading."""
with pytest.raises(ValueError, match="Unsupported video codec"):
VideoCodecValue(environ=False).to_python(raw)
-3
View File
@@ -499,6 +499,3 @@ def build_telephony_config():
"default_country": country,
"international_phone_number": international,
}
CACHE_SCAN_ITERSIZE = 500
+66 -15
View File
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines
import json
import tomllib
import warnings
from os import path
from socket import gethostbyname, gethostname
@@ -37,22 +37,34 @@ GB = 1024 * MB
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
class VideoCodecValue(values.Value):
"""
A video codec name, normalized to lowercase and validated against the codecs
supported by the LiveKit client, so that a typo fails at startup instead of
silently downgrading every publisher to another codec.
"""
codecs = frozenset(("vp8", "h264", "vp9", "av1"))
def to_python(self, value):
"""Normalize the codec name and ensure it is a supported one."""
codec = super().to_python(value).strip().lower()
if codec not in self.codecs:
raise ValueError(
f"Unsupported video codec {value!r}, "
f"expected one of: {', '.join(sorted(self.codecs))}."
)
return codec
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
@@ -275,6 +287,7 @@ class Base(Configuration):
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
@@ -284,7 +297,6 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
@@ -469,6 +481,9 @@ class Base(Configuration):
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -675,6 +690,9 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
)
CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None,
@@ -865,7 +883,7 @@ class Base(Configuration):
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
)
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
)
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
@@ -1110,6 +1128,12 @@ class Base(Configuration):
environ_prefix=None,
)
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -1122,10 +1146,16 @@ class Base(Configuration):
"style": "{",
},
},
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
"filters": ["silence_expected_401"],
},
},
# Override root logger to send it to console
@@ -1136,6 +1166,20 @@ class Base(Configuration):
),
},
"loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"dockerflow": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_DOCKERFLOW",
environ_prefix="",
)
},
"core": {
"handlers": ["console"],
"level": values.Value(
@@ -1211,7 +1255,14 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
)
sentry_sdk.set_tag("application", "backend")
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.30.0"
version = "1.31.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.30.0"
version = "1.31.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+5 -15
View File
@@ -4,12 +4,12 @@ USER node
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
COPY --chown=node:node ./src/frontend/package.json ./package.json
COPY --chown=node:node ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
COPY --chown=node:node .dockerignore ./.dockerignore
COPY --chown=node:node ./src/frontend/ .
### ---- Front-end builder image ----
@@ -42,20 +42,10 @@ ENV VITE_APP_TITLE=${VITE_APP_TITLE}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl
RUN apk del curl
USER nginx
# Un-privileged user running the application
+38 -45
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.30.0",
"version": "1.31.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.30.0",
"version": "1.31.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
@@ -23,18 +23,18 @@
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"humanize-duration": "3.34.1",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.414.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.10",
"react-i18next": "17.0.12",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
@@ -1720,28 +1720,28 @@
"license": "MIT"
},
"node_modules/@posthog/browser-common": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.2.tgz",
"integrity": "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.46.8",
"@posthog/types": "^1.402.0"
"@posthog/core": "^1.48.11",
"@posthog/types": "^1.405.3"
}
},
"node_modules/@posthog/core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
"version": "1.53.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.53.2.tgz",
"integrity": "sha512-Knx8442G2LyPVIzLvhcBX/TIdeHpZhrYrtZou3Uw8FWfbr9gQtQeOZDCog3MWcDxJ+4vAnMAAOzf5cYpc7Gxyw==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.407.0"
"@posthog/types": "^1.411.1"
}
},
"node_modules/@posthog/types": {
"version": "1.407.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
"version": "1.411.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.411.1.tgz",
"integrity": "sha512-Gd7tnSYctcSXup3naVlAgavvenByRao6rsSYdMgWgP35KE9jFn+rMHNRJeI5LNjJuLzMXHEVcgzAN7xcATefdw==",
"license": "MIT"
},
"node_modules/@react-aria/overlays": {
@@ -6354,11 +6354,12 @@
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/htmlparser2": {
@@ -6401,9 +6402,9 @@
}
},
"node_modules/humanize-duration": {
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
"version": "3.34.1",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.34.1.tgz",
"integrity": "sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==",
"license": "Unlicense",
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
@@ -9131,16 +9132,16 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.414.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
"version": "1.418.10",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz",
"integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==",
"license": "(Apache-2.0 AND MIT)",
"dependencies": {
"@posthog/browser-common": "^0.4.0",
"@posthog/core": "^1.46.9",
"@posthog/types": "^1.402.2",
"@posthog/browser-common": "^0.5.0",
"@posthog/core": "^1.48.8",
"@posthog/types": "^1.405.1",
"core-js": "^3.49.0",
"dompurify": "^3.4.12",
"dompurify": "^3.4.13",
"fflate": "^0.4.8",
"preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1",
@@ -9435,13 +9436,13 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"version": "17.0.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -11608,14 +11609,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walk-sync": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.30.0",
"version": "1.31.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -30,18 +30,18 @@
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"humanize-duration": "3.34.1",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.414.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.10",
"react-i18next": "17.0.12",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
+2 -1
View File
@@ -3,7 +3,7 @@ import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import type { Track } from 'livekit-client'
import type { Track, VideoCodec } from 'livekit-client'
type Source = Track.Source
export interface ApiConfig {
@@ -62,6 +62,7 @@ export interface ApiConfig {
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: Source[]
default_video_codec: VideoCodec
}
transcription_destination?: string
max_participants_for_sound: number
@@ -18,7 +18,7 @@ export const fetchUser = (
}
): Promise<ApiUser | false> => {
return new Promise((resolve, reject) => {
fetchApi<ApiUser>('/users/me')
fetchApi<ApiUser>('/users/me/')
.then(resolve)
.catch((error) => {
// we assume that a 401 means the user is not logged in
@@ -1,5 +1,6 @@
import { Div } from '@/primitives'
import { ToastProvider } from './components/ToastProvider'
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
export const NotificationProvider = ({
@@ -12,5 +13,6 @@ export const NotificationProvider = ({
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
<RecordingWaitingForTracksNotification />
</Div>
)
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { RecordingMode } from '@/features/recording'
import { useRecordingWaitingForTracks } from '@/features/recording/hooks/useRecordingWaitingForTracks'
import { StyledToastContainer } from './StyledToastContainer'
export const RecordingWaitingForTracksNotification = () => {
const { t } = useTranslation('notifications')
const isTranscriptWaiting = useRecordingWaitingForTracks(
RecordingMode.Transcript
)
const isScreenRecordingWaiting = useRecordingWaitingForTracks(
RecordingMode.ScreenRecording
)
if (!isTranscriptWaiting && !isScreenRecordingWaiting) return null
return (
<StyledToastContainer role="status">
<HStack padding={14}>
<Text
margin={false}
className={css({
maxWidth: '22rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{t(
isTranscriptWaiting
? 'transcript.waitingForTracks'
: 'screenRecording.waitingForTracks'
)}
</Text>
</HStack>
</StyledToastContainer>
)
}
@@ -34,6 +34,15 @@ export const WaitingParticipantNotification = () => {
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
useEffect(() => {
const previousIds = new Set(prevWaitingParticipant?.map(({ id }) => id))
const hasNewWaitingParticipant = waitingParticipants.some(
({ id }) => !previousIds.has(id)
)
if (hasNewWaitingParticipant) {
triggerNotificationSound(NotificationType.ParticipantWaiting)
}
// Show notification when the first participant enters the waiting room
if (
!isParticipantListEmpty(waitingParticipants) &&
@@ -42,8 +51,6 @@ export const WaitingParticipantNotification = () => {
) {
setShowQuickActionsMessage(true)
triggerNotificationSound(NotificationType.ParticipantJoined)
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
@@ -11,7 +11,7 @@ export const useNotificationSound = () => {
participantJoined: [0, 1150],
handRaised: [1400, 180],
messageReceived: [1580, 300],
waiting: [2039, 710],
participantWaiting: [2039, 710],
success: [2740, 1304],
},
volume: notificationsSnap.soundNotificationVolume,
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
@@ -89,6 +90,8 @@ export const ParticipantTile: (
)
const isScreenShare = trackReference.source != Track.Source.Camera
const isRemoteScreenShare =
isScreenShare && !trackReference.participant.isLocal
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant)
@@ -98,11 +101,23 @@ export const ParticipantTile: (
})
const participantName = name || identity || 'Unknown'
// tileRef: fullscreen target, and the node the focus overlay listens on.
// setRefs merges it with the forwarded ref on the same node.
const tileRef = React.useRef<HTMLDivElement>(null)
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (typeof ref === 'function') ref(node)
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref]
)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -120,8 +135,41 @@ export const ParticipantTile: (
},
}
const isVideoTrack =
isTrackReference(trackReference) &&
trackReference.publication.kind === 'video'
let trackMedia: React.ReactNode = null
if (isVideoTrack) {
const videoTrack = (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
// Zoom toolbar stays out of picture-in-picture: that window has its own
// document and the fullscreen API is off. Follow-up PR can restore zoom
// there without the dead fullscreen button.
trackMedia =
isRemoteScreenShare && !disableTileControls ? (
<ScreenShareZoomableVideo tileRef={tileRef}>
{videoTrack}
</ScreenShareZoomableVideo>
) : (
videoTrack
)
} else if (isTrackReference(trackReference)) {
trackMedia = (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
}
return (
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && (
@@ -129,23 +177,7 @@ export const ParticipantTile: (
)}
{children ?? (
<>
{isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
{trackMedia}
<div className="lk-participant-placeholder">
<ParticipantPlaceholder
color={participantColor}
@@ -164,6 +196,7 @@ export const ParticipantTile: (
{!disableMetadata && !disableTileControls && (
<ParticipantTileFocus
trackRef={trackReference}
tileRef={tileRef}
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
@@ -7,38 +7,64 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
tileRef: React.RefObject<HTMLDivElement | null>
}
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
// Pointer-events none so this overlay doesn't block the zoom surface below.
// The tile node still gets the mouse events, so we listen on it directly
// rather than lifting the state up: this keeps mouse moves from re-rendering
// the tile and the video it contains.
const FadeOverlay = ({
children,
hasKeyboardFocus,
tileRef,
}: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
useEffect(() => {
const tile = tileRef.current
if (!tile) return
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
const handleActivity = () => {
setActive(true)
clearIdleTimer()
idleTimerRef.current = window.setTimeout(
() => setActive(false),
MOUSE_IDLE_TIME
)
}
const handleLeave = () => {
clearIdleTimer()
setActive(false)
}, MOUSE_IDLE_TIME)
}
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
tile.addEventListener('mouseenter', handleActivity)
tile.addEventListener('mousemove', handleActivity)
tile.addEventListener('mouseleave', handleLeave)
useEffect(() => clearIdleTimer, [])
return () => {
clearIdleTimer()
tile.removeEventListener('mouseenter', handleActivity)
tile.removeEventListener('mousemove', handleActivity)
tile.removeEventListener('mouseleave', handleLeave)
}
}, [tileRef])
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
@@ -50,15 +76,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
alignItems: 'center',
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
data-visible={isVisible || undefined}
aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
>
{isVisible && children}
</div>
@@ -67,9 +88,11 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
export const ParticipantTileFocus = ({
trackRef,
tileRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
tileRef: React.RefObject<HTMLDivElement | null>
hasKeyboardFocus: boolean
}) => {
const participant = trackRef.participant
@@ -78,7 +101,7 @@ export const ParticipantTileFocus = ({
const canMute = useCanMute(participant)
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -87,6 +110,7 @@ export const ParticipantTileFocus = ({
display: 'flex',
opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards',
pointerEvents: 'auto',
_hover: {
opacity: 0.95,
},
@@ -94,7 +118,7 @@ export const ParticipantTileFocus = ({
>
<HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
{!isScreenShare && (
<>
{isLocal ? (
<EffectsButton />
@@ -102,8 +126,6 @@ export const ParticipantTileFocus = ({
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
@@ -1,32 +0,0 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -1,5 +1,6 @@
import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi'
import { captureEvent } from '@/features/analytics/telemetry'
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
export interface EnterRoomParams {
@@ -17,13 +18,23 @@ export const enterRoom = async ({
allowEntry,
participantId,
}: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
}),
})
try {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
}),
})
} catch (error) {
if (error instanceof ApiError && error.statusCode === 404) {
captureEvent('lobby_entry_participant_gone', {
room_id: roomId,
allow_entry: allowEntry,
})
}
throw error
}
}
export function useEnterRoom(
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
status: string
username: string
color: string
entered_at: string
}
export type WaitingParticipantsResponse = {
@@ -9,6 +9,14 @@ import {
} from '../../participants/api/listWaitingParticipants'
import { reportError } from '@/features/analytics/telemetry'
const toTimestamp = (participant: WaitingParticipant): number =>
Date.parse(participant.entered_at)
export const sortWaitingParticipants = (
participants: WaitingParticipant[]
): WaitingParticipant[] =>
[...participants].sort((a, b) => toTimestamp(a) - toTimestamp(b))
export const useWaitingParticipants = () => {
const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice
@@ -22,7 +30,10 @@ export const useWaitingParticipants = () => {
})
const waitingParticipants = useMemo(
() => (canManageLobby ? waitingData?.participants || [] : []),
() =>
canManageLobby
? sortWaitingParticipants(waitingData?.participants || [])
: [],
[waitingData, canManageLobby]
)
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { RecordingMode } from '@/features/recording'
import { useRecordingStatuses } from './useRecordingStatuses'
const STARTING_NOTIFICATION_DELAY = 15_000
export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
const { isStarting } = useRecordingStatuses(mode)
const tracks = useTracks(
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
{ onlySubscribed: false }
)
const [delayElapsed, setDelayElapsed] = useState(false)
useEffect(() => {
setDelayElapsed(false)
if (!isStarting) return
const timeout = window.setTimeout(
() => setDelayElapsed(true),
STARTING_NOTIFICATION_DELAY
)
return () => window.clearTimeout(timeout)
}, [isStarting, mode])
return isStarting && delayElapsed && tracks.length === 0
}
@@ -107,7 +107,7 @@ export const Conference = ({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
videoCodec: apiConfig?.livekit.default_video_codec ?? 'vp9',
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -129,6 +129,7 @@ export const Conference = ({
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
apiConfig?.livekit.default_video_codec,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { ApiError } from '@/api/ApiError'
export const POLL_INTERVAL_MS = 1000
export const LAZY_POLL_INTERVAL_MS = 10_000
export const POLL_INTERVAL_MS = 4_000
export const LAZY_POLL_INTERVAL_MS = 15_000
export const LobbyProvider = () => {
const room = useRoomContext()
@@ -79,7 +79,7 @@ export const LobbyProvider = () => {
// 3. Rights regained.
const prevCanManageLobby = usePrevious(canManageLobby)
useEffect(() => {
if (!prevCanManageLobby && canManageLobby && isConnected) {
if (prevCanManageLobby != canManageLobby && isConnected) {
fetchIfManager()
}
}, [
@@ -16,7 +16,7 @@ const Card = styled('div', {
borderRadius: '0.25rem',
boxShadow: '',
width: '100%',
maxWidth: '380px',
maxWidth: '410px',
minHeight: '196px',
},
})
@@ -229,7 +229,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
return (
<Card
style={{
maxWidth: '380px',
maxWidth: '410px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
@@ -8,7 +8,7 @@ import {
} from '../api/requestEntry'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000
export const POLL_INTERVAL_MS = 3_000
export const useLobby = ({
roomId,
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
useEffect(() => {
if (!isAnalyticsEnabled) return
const handleConnection = () => {
const handleConnection = async () => {
// Preserve original connection timestamp across reconnections to measure
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
void captureMediaEvent('connection-event', {})
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
}
const handleReconnect = () => {
captureEvent('reconnect-event')
}
const handleReconnected = () => {
captureEvent('reconnected-event')
const handleReconnected = async () => {
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
}
const handleSignalingConnect = () => {
@@ -0,0 +1,76 @@
import { Button } from '@/primitives'
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
// Keeps the fullscreen state here rather than on the toolbar, so entering or
// leaving fullscreen does not re-render the zoom controls.
export const ScreenShareFullscreenButton = memo(
({
containerRef,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const [isFullscreen, setIsFullscreen] = useState(false)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
// Covers Esc and browser UI exits, not just this button.
// Only this tile's instance announces to avoid duplicates with multiple shares.
useEffect(() => {
const onChange = () => {
const isThisTileFullscreen =
document.fullscreenElement === containerRef.current
setIsFullscreen(isThisTileFullscreen)
if (isThisTileFullscreen) {
wasThisTileFullscreen.current = true
announce(t('fullScreenEntered'), 'assertive')
} else if (wasThisTileFullscreen.current) {
wasThisTileFullscreen.current = false
announce(t('fullScreenExited'), 'assertive')
}
}
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}, [announce, t, containerRef])
const toggleFullScreen = useCallback(async () => {
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen()
} else {
// Tile container so zoom controls stay visible in fullscreen.
await containerRef.current?.requestFullscreen()
}
} catch (error) {
console.error('Error toggling fullscreen:', error)
}
}, [containerRef])
if (!document.fullscreenEnabled) return null
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
onPress={toggleFullScreen}
>
{isFullscreen ? (
<RiCollapseDiagonalLine size={20} />
) : (
<RiExpandDiagonalLine size={20} />
)}
</Button>
)
}
)
ScreenShareFullscreenButton.displayName = 'ScreenShareFullscreenButton'
@@ -0,0 +1,165 @@
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiFullscreenExitLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Toolbar } from 'react-aria-components'
import { useEffect, useRef } from 'react'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
interface ScreenShareZoomControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>
isZoomed: boolean
zoomPercentage: number
canZoomIn: boolean
canZoomOut: boolean
onZoomIn: () => void
onZoomOut: () => void
onResetZoom: () => void
}
export const ScreenShareZoomControls = ({
containerRef,
isZoomed,
zoomPercentage,
canZoomIn,
canZoomOut,
onZoomIn,
onZoomOut,
onResetZoom,
}: ScreenShareZoomControlsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
// Back at 100 % the collapsible controls are disabled and hidden, which drops
// keyboard focus on the body. Hand it to the zoom in button instead, the only
// control of that group still reachable.
useEffect(() => {
if (isZoomed || !hadFocusInCollapsibleRef.current) return
hadFocusInCollapsibleRef.current = false
zoomInButtonRef.current?.focus()
}, [isZoomed])
const wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
return (
<div
className={css({
position: 'absolute',
bottom: '12px',
right: '12px',
zIndex: 2,
pointerEvents: 'auto',
})}
>
<Toolbar
aria-label={t('toolbarLabel')}
className={css({
display: 'flex',
alignItems: 'center',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
padding: '0.5rem',
opacity: 0.7,
transition: 'opacity 200ms linear',
_hover: {
opacity: 0.95,
},
})}
>
<span className={srOnly}>
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
</span>
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
trick keeps overflow:hidden from clipping focus rings. */}
<div
className={css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
padding: '3px',
margin: '-3px',
})}
style={{
maxWidth: isZoomed ? '12rem' : '0',
opacity: isZoomed ? 1 : 0,
}}
aria-hidden={!isZoomed}
onFocus={() => {
hadFocusInCollapsibleRef.current = true
}}
onBlur={(e) => {
// Disabling a focused button blurs it with no relatedTarget, so the
// flag must survive that case for the effect above to rescue focus.
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
}}
>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fitToWindow')}
aria-label={t('fitToWindow')}
isDisabled={!isZoomed}
onPress={onResetZoom}
>
<RiFullscreenExitLine size={20} />
</Button>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomOutWithShortcut', {
shortcut: wheelShortcut,
})}
aria-label={t('zoomOut')}
isDisabled={!isZoomed || !canZoomOut}
onPress={onZoomOut}
>
<RiZoomOutLine size={20} />
</Button>
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
<span
aria-hidden="true"
className={css({
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.25rem',
textAlign: 'center',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 0.25rem',
whiteSpace: 'nowrap',
})}
>
{zoomPercentage} %
</span>
</div>
<Button
ref={zoomInButtonRef}
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcut })}
aria-label={t('zoomIn')}
isDisabled={!canZoomIn}
onPress={onZoomIn}
>
<RiZoomInLine size={20} />
</Button>
<ScreenShareFullscreenButton containerRef={containerRef} />
</Toolbar>
</div>
)
}
@@ -0,0 +1,98 @@
import { css } from '@/styled-system/css'
import { useEffect, useRef, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
interface ScreenShareZoomableVideoProps {
tileRef: React.RefObject<HTMLDivElement | null>
children: ReactNode
}
// The video comes in as children so that a zoom change, which only re-renders
// this wrapper, leaves the video subtree untouched.
export const ScreenShareZoomableVideo = ({
tileRef,
children,
}: ScreenShareZoomableVideoProps) => {
const zoom = useScreenShareZoom()
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
// SR announcement: announce zoom level on change, with a one-time pan hint
// on the first zoom above 100 % per session.
const prevZoomRef = useRef(zoom.zoomPercentage)
const hasAnnouncedPanHint = useRef(false)
useEffect(() => {
if (prevZoomRef.current === zoom.zoomPercentage) return
const wasAtDefault = prevZoomRef.current <= 100
prevZoomRef.current = zoom.zoomPercentage
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
hasAnnouncedPanHint.current = true
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
} else {
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
}
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
// Attach keyboard listener on the tile container (has tabIndex=0).
useEffect(() => {
const el = tileRef.current
if (!el) return
el.addEventListener('keydown', zoom.handleKeyDown)
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
}, [tileRef, zoom.handleKeyDown])
// Native wheel listener with { passive: false } so preventDefault works.
useEffect(() => {
const el = zoom.surfaceElRef.current
if (!el) return
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
return () => el.removeEventListener('wheel', zoom.handleWheel)
}, [zoom.handleWheel, zoom.surfaceElRef])
return (
<>
<div
ref={zoom.surfaceElRef}
className={css({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
userSelect: 'none',
// Leaves the browser's native pinch-zoom available on touch devices
// while still routing single-pointer drags to useMove for panning.
touchAction: 'pinch-zoom',
})}
{...zoom.moveProps}
>
<div
ref={zoom.transformElRef}
style={{
width: '100%',
height: '100%',
pointerEvents: 'none',
transformOrigin: 'center center',
}}
>
{children}
</div>
</div>
<ScreenShareZoomControls
containerRef={tileRef}
isZoomed={zoom.isZoomed}
zoomPercentage={zoom.zoomPercentage}
canZoomIn={zoom.canZoomIn}
canZoomOut={zoom.canZoomOut}
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onResetZoom={zoom.resetZoom}
/>
</>
)
}
@@ -1,17 +1,16 @@
import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import {
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
/**
* Sets initial video quality for new participants as they join.
* Applies the saved reception quality to every remote camera.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const VideoResolutionSubscription = () => {
@@ -19,30 +18,40 @@ export const VideoResolutionSubscription = () => {
const room = useRoomContext()
useEffect(() => {
if (!room) return
if (!room || videoSubscribeQuality === undefined) return
const handleTrackPublished = (
publication: RemoteTrackPublication,
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
const applyQuality = (publication: RemoteTrackPublication) => {
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
publication.kind !== Track.Kind.Video ||
publication.source === Track.Source.ScreenShare ||
publication.videoQuality === videoSubscribeQuality
) {
publication.setVideoQuality(videoSubscribeQuality)
return
}
publication.setVideoQuality(videoSubscribeQuality)
}
// Cameras we are already receiving: those published before this effect ran,
// and all of them again whenever the preference changes mid-call.
room.remoteParticipants.forEach((participant) =>
participant.videoTrackPublications.forEach(applyQuality)
)
const handleTrackPublished = (publication: RemoteTrackPublication) =>
applyQuality(publication)
// TrackPublished is not raised for cameras that were already sending when we
// joined, but it is the earliest point for the ones that start after us.
const handleTrackSubscribed = (
_track: RemoteTrack,
publication: RemoteTrackPublication
) => applyQuality(publication)
room.on(RoomEvent.TrackPublished, handleTrackPublished)
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
room.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
}
}, [room, videoSubscribeQuality])
@@ -2,23 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { Crisp } from 'crisp-sdk-web'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
import {
useIsSupportEnabled,
openSupportChat,
} from '@/features/support/hooks/useSupport'
export const SupportMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled || !Crisp) {
if (!isSupportEnabled) {
return
}
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => {
Crisp?.chat.open()
}}
onAction={openSupportChat}
>
<RiQuestionLine size={20} />
{t('support')}
@@ -0,0 +1,270 @@
import { useCallback, useRef, useState } from 'react'
import { useMove } from 'react-aria'
import type { MoveMoveEvent } from '@react-types/shared'
import {
FULL_PICTURE_RATIO,
MAX_ZOOM,
MIN_ZOOM,
PAN_STEP,
WHEEL_ZOOM_SPEED,
ZOOM_STEP,
type PanOffset,
clampPan,
clampZoom,
getCursorFromZoomState,
getCursorPercentsFromWheelEvent,
getPanDeltaPercentsFromMove,
getPictureRatio,
getWheelPanOffset,
getZoomTransform,
} from '../utils/screenShareZoom'
/**
* Manages zoom and pan state for a remote screen share.
*
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
* (via transformElRef / surfaceElRef) so dragging and panning never re-render.
* React state only tracks what the toolbar displays, and only updates when
* the zoom level actually changes.
*
* Drag/touch panning is handled by react-aria's useMove (moveProps).
* The wheel listener (non-passive) zooms on Ctrl/Cmd+scroll and pans on a
* two-finger trackpad scroll once zoomed.
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
* tile container (which has tabIndex=0 and focus).
*/
export const useScreenShareZoom = () => {
const zoomRef = useRef(MIN_ZOOM)
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
const draggingRef = useRef(false)
// The consumer binds these to the inner transform div and the outer drag surface.
const transformElRef = useRef<HTMLDivElement | null>(null)
const surfaceElRef = useRef<HTMLDivElement | null>(null)
// Mirrors zoomRef for the toolbar. Panning never publishes: the toolbar
// shows the zoom level, not the position.
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM)
const syncToolbar = useCallback(() => setZoomLevel(zoomRef.current), [])
const applyTransform = useCallback(() => {
const el = transformElRef.current
if (!el) return
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
}, [])
// The video is letterboxed inside the surface by object-fit: contain, so the
// pan bounds depend on how much of the surface the picture actually covers.
// Read live rather than cached: both the tile and the shared resolution can
// change at any time.
const readPictureRatio = useCallback(() => {
const surface = surfaceElRef.current
const video = transformElRef.current?.querySelector('video')
if (!surface || !video) return FULL_PICTURE_RATIO
return getPictureRatio(
surface.clientWidth,
surface.clientHeight,
video.videoWidth,
video.videoHeight
)
}, [])
const applyCursor = useCallback(() => {
const el = surfaceElRef.current
if (!el) return
el.style.cursor = getCursorFromZoomState(
zoomRef.current,
draggingRef.current
)
}, [])
const setZoom = useCallback(
(next: number) => {
zoomRef.current = next
panRef.current =
next <= MIN_ZOOM
? { x: 0, y: 0 }
: clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
syncToolbar()
},
[applyTransform, applyCursor, syncToolbar, readPictureRatio]
)
const zoomIn = useCallback(
() => setZoom(clampZoom(zoomRef.current + ZOOM_STEP)),
[setZoom]
)
const zoomOut = useCallback(
() => setZoom(clampZoom(zoomRef.current - ZOOM_STEP)),
[setZoom]
)
const resetZoom = useCallback(() => setZoom(MIN_ZOOM), [setZoom])
// Cancels the scale() that multiplies translate(), so the picture follows
// the pointer 1:1 and keyboard steps keep the same visual size.
const panBy = useCallback(
(dx: number, dy: number) => {
const zoom = zoomRef.current
panRef.current = clampPan(
{
x: panRef.current.x + dx / zoom,
y: panRef.current.y + dy / zoom,
},
zoom,
readPictureRatio()
)
applyTransform()
},
[applyTransform, readPictureRatio]
)
// Must be attached with { passive: false } so preventDefault() blocks
// the browser's native Ctrl+scroll page zoom. Trackpad pinch arrives
// here as a wheel event with ctrl/cmd already set.
const handleWheel = useCallback(
(e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
e.stopPropagation()
const target = e.currentTarget as HTMLElement
const prev = zoomRef.current
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
const next = clampZoom(prev + delta)
if (next <= MIN_ZOOM) {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
} else {
const { cursorXPercent, cursorYPercent } =
getCursorPercentsFromWheelEvent(e, target)
zoomRef.current = next
panRef.current = getWheelPanOffset({
pan: panRef.current,
prevZoom: prev,
nextZoom: next,
cursorXPercent,
cursorYPercent,
ratio: readPictureRatio(),
})
}
applyTransform()
applyCursor()
syncToolbar()
return
}
// Two-finger trackpad scroll: pan only once zoomed, otherwise leave
// the event alone so the page can still scroll.
if (zoomRef.current <= MIN_ZOOM) return
const el = surfaceElRef.current
if (!el) return
e.preventDefault()
e.stopPropagation()
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
-e.deltaX,
-e.deltaY,
el
)
panBy(deltaXPercent, deltaYPercent)
},
[applyTransform, applyCursor, syncToolbar, readPictureRatio, panBy]
)
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
// here because moveProps is on the zoom surface, while focus is on the tile
// container, see handleKeyDown below.
const { moveProps } = useMove({
onMoveStart() {
if (zoomRef.current <= MIN_ZOOM) return
draggingRef.current = true
applyCursor()
},
onMove(e: MoveMoveEvent) {
if (zoomRef.current <= MIN_ZOOM) return
const el = surfaceElRef.current
if (!el) return
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
e.deltaX,
e.deltaY,
el
)
panBy(deltaXPercent, deltaYPercent)
},
onMoveEnd() {
draggingRef.current = false
applyTransform()
applyCursor()
},
})
// Attached to the tile container (not the zoom surface) where keyboard
// focus lives. Arrows pan, +/-/0 zoom.
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isZoomed = zoomRef.current > MIN_ZOOM
if (!isZoomed && e.key !== '+' && e.key !== '=') return
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
panBy(PAN_STEP, 0)
break
case 'ArrowRight':
e.preventDefault()
panBy(-PAN_STEP, 0)
break
case 'ArrowUp':
e.preventDefault()
panBy(0, PAN_STEP)
break
case 'ArrowDown':
e.preventDefault()
panBy(0, -PAN_STEP)
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case '0':
e.preventDefault()
resetZoom()
break
}
},
[panBy, zoomIn, zoomOut, resetZoom]
)
return {
zoomPercentage: Math.round(zoomLevel * 100),
isZoomed: zoomLevel > MIN_ZOOM,
canZoomIn: zoomLevel < MAX_ZOOM,
canZoomOut: zoomLevel > MIN_ZOOM,
transformElRef,
surfaceElRef,
moveProps,
zoomIn,
zoomOut,
resetZoom,
handleWheel,
handleKeyDown,
}
}
@@ -0,0 +1,128 @@
export const MIN_ZOOM = 1
export const MAX_ZOOM = 4
export const ZOOM_STEP = 0.1
export const WHEEL_ZOOM_SPEED = 0.002
export const PAN_STEP = 5
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
// cursor offset is measured from and the half extent the pan is clamped
// against, so the two stay consistent by construction.
export const HALF_EXTENT_PERCENT = 50
export interface PanOffset {
x: number
y: number
}
// Fraction of the surface each axis of the picture covers, in [0, 1].
export interface PictureRatio {
x: number
y: number
}
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
export const clampZoom = (value: number) => {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
}
// Restrict pan so the picture always covers the view. Pan is a % of the
// surface, in which object-fit: contain letterboxes the picture: its half
// extent is `ratio * 50` against a view half extent of 50, and scaling by
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
export const clampPan = (
pan: PanOffset,
zoom: number,
ratio: PictureRatio
): PanOffset => {
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
return {
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
}
}
// Per-axis fraction of the surface covered by an object-fit: contain picture.
export const getPictureRatio = (
surfaceWidth: number,
surfaceHeight: number,
videoWidth: number,
videoHeight: number
): PictureRatio => {
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
return FULL_PICTURE_RATIO
}
const surfaceRatio = surfaceWidth / surfaceHeight
const videoRatio = videoWidth / videoHeight
return surfaceRatio > videoRatio
? { x: videoRatio / surfaceRatio, y: 1 }
: { x: 1, y: surfaceRatio / videoRatio }
}
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
}
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
if (zoom <= MIN_ZOOM) return 'default'
return dragging ? 'grabbing' : 'grab'
}
// Keep the content point under the cursor anchored while zooming. With
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
// `z * (offset + pan)`, so holding it still gives:
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
export const getWheelPanOffset = ({
pan,
prevZoom,
nextZoom,
cursorXPercent,
cursorYPercent,
ratio,
}: {
pan: PanOffset
prevZoom: number
nextZoom: number
cursorXPercent: number
cursorYPercent: number
ratio: PictureRatio
}): PanOffset => {
const panShift = 1 / nextZoom - 1 / prevZoom
return clampPan(
{
x: pan.x + cursorXPercent * panShift,
y: pan.y + cursorYPercent * panShift,
},
nextZoom,
ratio
)
}
// Convert cursor pixel position to a % offset from the surface center.
export const getCursorPercentsFromWheelEvent = (
e: WheelEvent,
target: HTMLElement
) => {
const rect = target.getBoundingClientRect()
return {
cursorXPercent:
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
cursorYPercent:
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
}
}
// Convert useMove pixel deltas to % of the surface dimensions.
export const getPanDeltaPercentsFromMove = (
deltaX: number,
deltaY: number,
surface: HTMLElement
) => {
const rect = surface.getBoundingClientRect()
return {
deltaXPercent: (deltaX / rect.width) * 100,
deltaYPercent: (deltaY / rect.height) * 100,
}
}
@@ -24,7 +24,8 @@ const Heading = styled('h1', {
})
const buttonClass = css({
width: { base: '100%', xsm: 'auto' },
width: '100%',
flex: 1,
})
enum DisconnectReasonKey {
@@ -64,8 +65,8 @@ const FeedbackRoute = () => {
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<Stack
direction={{ base: 'column', xsm: 'row' }}
width={{ base: '100%', xsm: 'auto' }}
maxWidth="380px"
width="100%"
maxWidth="410px"
>
{showBackButton && (
<Button
@@ -34,7 +34,7 @@ const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const room = useRoomContext()
const { localParticipant, remoteParticipants } = room
const { localParticipant } = room
const {
videoDeviceId,
@@ -88,22 +88,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
})
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
@@ -231,9 +215,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
saveVideoSubscribeQuality(Number(String(key)))
}}
style={{
width: '100%',
+28 -1
View File
@@ -16,6 +16,10 @@ export type ShortcutId =
| 'recording'
| 'reaction'
| 'fullscreen'
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'zoom-pan'
export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id)
@@ -24,7 +28,7 @@ export type ShortcutDescriptor = {
id: ShortcutId
category: ShortcutCategory
shortcut?: Shortcut
kind?: 'press' | 'longPress'
kind?: 'press' | 'longPress' | 'arrows'
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string
}
@@ -86,4 +90,27 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
},
// Screen share zoom keys are unmodified, so they are bound on the focused
// tile instead of being registered globally. They are listed here so the
// shortcuts panel stays exhaustive.
{
id: 'zoom-in',
category: 'interaction',
shortcut: { key: '+' },
},
{
id: 'zoom-out',
category: 'interaction',
shortcut: { key: '-' },
},
{
id: 'zoom-reset',
category: 'interaction',
shortcut: { key: '0' },
},
{
id: 'zoom-pan',
category: 'interaction',
kind: 'arrows',
},
]
@@ -25,6 +25,7 @@ export const formatShortcutLabelForSR = (
shiftLabel,
plusLabel,
noShortcutLabel,
keyLabels,
}: {
controlLabel: string
commandLabel: string
@@ -33,10 +34,12 @@ export const formatShortcutLabelForSR = (
shiftLabel: string
plusLabel: string
noShortcutLabel: string
// Spelled-out names for keys screen readers may skip or mispronounce.
keyLabels?: Record<string, string>
}
) => {
if (!shortcut) return noShortcutLabel
const key = shortcut.key?.toUpperCase()
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const altWord = isMacintosh() ? optionLabel : altLabel
@@ -12,6 +12,9 @@ export const useShortcutFormatting = () => {
const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.visual.arrows')
}
if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.visual.hold', { key: label || '?' })
@@ -23,6 +26,9 @@ export const useShortcutFormatting = () => {
const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.sr.arrows')
}
if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.sr.hold', { key: label || '?' })
@@ -35,6 +41,10 @@ export const useShortcutFormatting = () => {
shiftLabel: t('shortcutsPanel.sr.shift'),
plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
keyLabels: {
'+': t('shortcutsPanel.sr.plusKey'),
'-': t('shortcutsPanel.sr.minusKey'),
},
})
},
[t]
@@ -1,20 +1,45 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
let crisp: CrispSdk | undefined
let crispPromise: Promise<CrispSdk> | undefined
const loadCrisp = (): Promise<CrispSdk> => {
crispPromise ??= import('crisp-sdk-web')
.then((module) => {
crisp = module.Crisp
return module.Crisp
})
.catch((error) => {
crispPromise = undefined
throw error
})
return crispPromise
}
export const openSupportChat = () => {
if (!crisp?.isCrispInjected()) return
crisp.chat.open()
}
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
if (!crisp?.isCrispInjected()) return
const { id, email } = user
Crisp.setTokenId(`meet-${id}`)
if (email) Crisp.user.setEmail(email)
crisp.setTokenId(`meet-${id}`)
if (email) crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
if (!Crisp.isCrispInjected()) return
Crisp.setTokenId()
Crisp.session.reset()
if (!crisp?.isCrispInjected()) return
crisp.setTokenId()
crisp.session.reset()
}
export type useSupportProps = {
@@ -22,26 +47,70 @@ export type useSupportProps = {
isDisabled?: boolean
}
// Configure Crisp chat for real-time support across all pages.
const IDLE_TIMEOUT_MS = 10_000
const scheduleWhenIdle = (callback: () => void): (() => void) => {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, {
timeout: IDLE_TIMEOUT_MS,
})
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(callback, 1)
return () => window.clearTimeout(handle)
}
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
const { user } = useUser()
const [isInjected, setIsInjected] = useState(
() => crisp?.isCrispInjected() ?? false
)
useEffect(() => {
if (!id || Crisp.isCrispInjected() || isDisabled) return
Crisp.configure(id)
Crisp.setHideOnMobile(true)
if (!id || isDisabled) return
if (crisp?.isCrispInjected()) {
setIsInjected(true)
return
}
let cancelled = false
const cancelIdle = scheduleWhenIdle(() => {
void loadCrisp()
.then((sdk) => {
if (cancelled) return
if (!sdk.isCrispInjected()) {
sdk.configure(id)
sdk.setHideOnMobile(true)
}
setIsInjected(true)
})
.catch((error) => {
if (!cancelled) {
console.error('Failed to initialize support chat', error)
}
})
})
return () => {
cancelled = true
cancelIdle()
}
}, [id, isDisabled])
useEffect(() => {
if (!user) return
if (!user || !isInjected || isDisabled) return
initializeSupportSession(user)
}, [user])
}, [user, isInjected, isDisabled])
return null
}
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
// So we need to safely check if Crisp is available and not blocked
// Some users block the chat widget, so check its availability safely.
const isCrispAvailable = () => {
try {
return !!window?.$crisp?.is
+7 -15
View File
@@ -35,30 +35,21 @@ const LoginHint = () => {
<div
className={css({
position: 'absolute',
top: '103px',
right: '110px',
top: 'calc(100% + 12px)',
right: 0,
zIndex: '100',
outline: 'none',
padding: '1.25rem',
maxWidth: '350px',
width: 'max-content',
maxWidth: 'min(350px, calc(100vw - 2rem))',
boxShadow: '0 2px 5px rgba(0 0 0 / 0.1)',
borderRadius: '1rem',
backgroundColor: 'primary.200',
display: 'none',
xsm: {
display: 'block',
},
sm: {
top: '131px',
right: '100px',
zIndex: '100',
},
_after: {
content: '""',
position: 'absolute',
top: '-10px',
right: '20%',
marginLeft: '-10px',
right: '1.5rem',
borderWidth: '0 10px 10px 10px',
borderStyle: 'solid',
borderColor: 'transparent transparent #E3E3FB transparent',
@@ -171,12 +162,13 @@ export const Header = () => {
<>
<div
className={css({
position: 'relative',
display: { base: 'none', xsm: 'block' },
})}
>
<LoginButton proConnectHint={false} />
<LoginHint />
</div>
<LoginHint />
</>
)}
{!!user && (
@@ -33,12 +33,14 @@
"accept": "Hereinlassen"
},
"transcript": {
"waitingForTracks": "Die Transkription wartet auf ein Mikrofon. Schalten Sie Ihr Mikrofon ein, damit die Transkription starten kann.",
"started": "{{name}} hat die Meeting-Transkription gestartet.",
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} möchte die Meeting-Transkription starten."
},
"screenRecording": {
"waitingForTracks": "Die Aufnahme wartet auf Audio oder Video. Schalten Sie Ihr Mikrofon oder Ihre Kamera ein oder teilen Sie Ihren Bildschirm, damit die Aufnahme starten kann.",
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
+29 -3
View File
@@ -480,7 +480,7 @@
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Aufzeichnung starten"
"recording": "Auch eine Videoaufzeichnung starten"
},
"button": {
"start": "Meeting-Transkription starten",
@@ -756,6 +756,24 @@
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
},
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
"zoomIn": "Vergrößern",
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
"zoomOut": "Verkleinern",
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
"wheelShortcut": "Steuerung plus Mausrad",
"wheelShortcutMac": "⌘+Mausrad",
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
"fitToWindow": "An Fenster anpassen",
"fullScreen": "Vollbild",
"exitFullScreen": "Vollbild beenden",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
"fullScreenEntered": "Vollbild aktiviert",
"fullScreenExited": "Vollbild deaktiviert"
},
"shortcutsPanel": {
"title": "Tastenkürzel",
"categories": {
@@ -775,7 +793,11 @@
"raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen",
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
},
"sr": {
"control": "Steuerung",
@@ -785,10 +807,14 @@
"shift": "Umschalt",
"plus": "plus",
"hold": "Halte {{key}} gedrückt",
"arrows": "Pfeiltasten",
"plusKey": "Plus-Taste",
"minusKey": "Minus-Taste",
"noShortcut": "Kein Tastenkürzel"
},
"visual": {
"hold": "Halte {{key}} gedrückt"
"hold": "Halte {{key}} gedrückt",
"arrows": "↑ ↓ ← →"
}
},
"fullScreenWarning": {
@@ -102,6 +102,9 @@
},
"messageReceived": {
"label": "Nachricht erhalten"
},
"participantWaiting": {
"label": "Person im Warteraum"
}
}
},
@@ -33,12 +33,14 @@
"accept": "Accept"
},
"transcript": {
"waitingForTracks": "Transcription is waiting for a microphone. Turn on your microphone so the transcription can start.",
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
"requested": "{{name}} wants to start the meeting transcription."
},
"screenRecording": {
"waitingForTracks": "Recording is waiting for audio or video. Turn on your microphone or camera, or share your screen, so the recording can start.",
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
+29 -3
View File
@@ -480,7 +480,7 @@
"destination": "A new document will be created on",
"destinationUnknown": "A new document will be created",
"language": "Meeting language:",
"recording": "Also start a recording"
"recording": "Also start a video recording"
},
"button": {
"start": "Start transcribing the meeting",
@@ -756,6 +756,24 @@
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
"zoomIn": "Zoom in",
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
"zoomOut": "Zoom out",
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
"wheelShortcut": "Control plus scroll wheel",
"wheelShortcutMac": "⌘+scroll wheel",
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
"fitToWindow": "Fit to window",
"fullScreen": "Full screen",
"exitFullScreen": "Exit full screen",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
"fullScreenEntered": "Full screen enabled",
"fullScreenExited": "Full screen disabled"
},
"shortcutsPanel": {
"title": "Keyboard shortcuts",
"categories": {
@@ -775,7 +793,11 @@
"raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants",
"open-shortcuts-settings": "Open shortcuts settings"
"open-shortcuts-settings": "Open shortcuts settings",
"zoom-in": "Zoom in on a shared screen",
"zoom-out": "Zoom out on a shared screen",
"zoom-reset": "Reset the shared screen zoom",
"zoom-pan": "Move around a zoomed shared screen"
},
"sr": {
"control": "Control",
@@ -785,10 +807,14 @@
"shift": "Shift",
"plus": "plus",
"hold": "Hold {{key}}",
"arrows": "Arrow keys",
"plusKey": "Plus key",
"minusKey": "Minus key",
"noShortcut": "No shortcut"
},
"visual": {
"hold": "Hold {{key}}"
"hold": "Hold {{key}}",
"arrows": "↑ ↓ ← →"
}
},
"fullScreenWarning": {
@@ -102,6 +102,9 @@
},
"messageReceived": {
"label": "Message received"
},
"participantWaiting": {
"label": "Participant waiting"
}
}
},
@@ -33,12 +33,14 @@
"accept": "Aceptar"
},
"transcript": {
"waitingForTracks": "La transcripción está esperando un micrófono. Activa el micrófono para que la transcripción pueda comenzar.",
"started": "{{name}} ha iniciado la transcripción de la reunión.",
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
"requested": "{{name}} ha solicitado iniciar la transcripción."
},
"screenRecording": {
"waitingForTracks": "La grabación está esperando audio o vídeo. Activa el micrófono o la cámara, o comparte la pantalla, para que la grabación pueda comenzar.",
"started": "{{name}} ha iniciado la grabación de la reunión.",
"stopped": "{{name}} ha detenido la grabación de la reunión.",
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
+24 -2
View File
@@ -479,7 +479,7 @@
"destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:",
"recording": "Iniciar también una grabación"
"recording": "Iniciar también una grabación de vídeo"
},
"button": {
"start": "Empezar a transcribir la reunión",
@@ -755,6 +755,24 @@
"muteParticipant": "Silenciar el micrófono de {{name}}",
"fullScreen": "Pantalla completa"
},
"screenShareZoom": {
"toolbarLabel": "Controles de zoom de la pantalla compartida",
"zoomIn": "Acercar",
"zoomInWithShortcut": "Acercar ({{shortcut}})",
"zoomOut": "Alejar",
"zoomOutWithShortcut": "Alejar ({{shortcut}})",
"wheelShortcut": "Control más rueda del ratón",
"wheelShortcutMac": "⌘+rueda del ratón",
"wheelShortcutHint": "Atajo: Control más rueda del ratón para acercar o alejar.",
"wheelShortcutHintMac": "Atajo: Comando más rueda del ratón para acercar o alejar.",
"fitToWindow": "Ajustar a la ventana",
"fullScreen": "Pantalla completa",
"exitFullScreen": "Salir de pantalla completa",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Arrastra con el ratón, o vuelve a la pantalla compartida y usa las flechas para moverte por la imagen.",
"fullScreenEntered": "Pantalla completa activada",
"fullScreenExited": "Pantalla completa desactivada"
},
"shortcutsPanel": {
"title": "Atajos de teclado",
"categories": {
@@ -774,7 +792,11 @@
"raise-hand": "Levantar o bajar la mano",
"toggle-chat": "Mostrar/Ocultar el chat",
"toggle-participants": "Mostrar/Ocultar los participantes",
"open-shortcuts-settings": "Abrir los ajustes de los atajos"
"open-shortcuts-settings": "Abrir los ajustes de los atajos",
"zoom-in": "Acercar una pantalla compartida",
"zoom-out": "Alejar una pantalla compartida",
"zoom-reset": "Restablecer el zoom de la pantalla compartida",
"zoom-pan": "Moverse por una pantalla compartida ampliada"
},
"sr": {
"control": "Control",
@@ -102,6 +102,9 @@
},
"messageReceived": {
"label": "Un mensaje recibido"
},
"participantWaiting": {
"label": "Una persona en la sala de espera"
}
}
},
@@ -33,12 +33,14 @@
"accept": "Accepter"
},
"transcript": {
"waitingForTracks": "La transcription attend un microphone. Activez votre microphone pour permettre à la transcription de démarrer.",
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
"requested": "{{name}} a demandé à démarrer la transcription."
},
"screenRecording": {
"waitingForTracks": "L’enregistrement attend un flux audio ou vidéo. Activez votre microphone ou votre caméra, ou partagez votre écran, pour permettre à l’enregistrement de démarrer.",
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
+29 -3
View File
@@ -480,7 +480,7 @@
"destination": "Un nouveau document sera créé sur",
"destinationUnknown": "Un nouveau document sera créé",
"language": "Langue de la réunion :",
"recording": "Démarrer aussi un enregistrement"
"recording": "Démarrer aussi un enregistrement vidéo"
},
"button": {
"start": "Commencer à transcrire la réunion",
@@ -756,6 +756,24 @@
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
"zoomIn": "Zoomer",
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
"zoomOut": "Dézoomer",
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
"wheelShortcut": "Contrôle plus molette",
"wheelShortcutMac": "⌘+molette",
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
"fitToWindow": "Ajuster à la fenêtre",
"fullScreen": "Plein écran",
"exitFullScreen": "Quitter le plein écran",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
"fullScreenEntered": "Plein écran activé",
"fullScreenExited": "Plein écran désactivé"
},
"shortcutsPanel": {
"title": "Raccourcis clavier",
"categories": {
@@ -775,7 +793,11 @@
"raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis",
"zoom-in": "Zoomer sur un écran partagé",
"zoom-out": "Dézoomer sur un écran partagé",
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
},
"sr": {
"control": "Contrôle",
@@ -785,10 +807,14 @@
"shift": "Majuscule",
"plus": "plus",
"hold": "Maintenir {{key}}",
"arrows": "Touches fléchées",
"plusKey": "Touche plus",
"minusKey": "Touche moins",
"noShortcut": "Aucun raccourci"
},
"visual": {
"hold": "Maintenir {{key}}"
"hold": "Maintenir {{key}}",
"arrows": "↑ ↓ ← →"
}
},
"fullScreenWarning": {
@@ -102,6 +102,9 @@
},
"messageReceived": {
"label": "Un message reçu"
},
"participantWaiting": {
"label": "Une personne en salle d’attente"
}
}
},
@@ -33,12 +33,14 @@
"accept": "Accepteren"
},
"transcript": {
"waitingForTracks": "De transcriptie wacht op een microfoon. Zet uw microfoon aan, zodat de transcriptie kan starten.",
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"requested": "{{name}} wil graag de transcriptie starten."
},
"screenRecording": {
"waitingForTracks": "De opname wacht op audio of video. Zet uw microfoon of camera aan of deel uw scherm, zodat de opname kan starten.",
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
+29 -3
View File
@@ -480,7 +480,7 @@
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
"recording": "Start ook een opname"
"recording": "Start ook een video-opname"
},
"button": {
"start": "Begin met het transcriberen van de vergadering",
@@ -756,6 +756,24 @@
"muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm"
},
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
"zoomIn": "Inzoomen",
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
"zoomOut": "Uitzoomen",
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
"wheelShortcut": "Control plus scrollwiel",
"wheelShortcutMac": "⌘+scrollwiel",
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
"fitToWindow": "Aanpassen aan venster",
"fullScreen": "Volledig scherm",
"exitFullScreen": "Volledig scherm verlaten",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
"fullScreenEntered": "Volledig scherm ingeschakeld",
"fullScreenExited": "Volledig scherm uitgeschakeld"
},
"shortcutsPanel": {
"title": "Sneltoetsen",
"categories": {
@@ -775,7 +793,11 @@
"raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen"
"open-shortcuts-settings": "Sneltoets-instellingen openen",
"zoom-in": "Inzoomen op een gedeeld scherm",
"zoom-out": "Uitzoomen op een gedeeld scherm",
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
},
"sr": {
"control": "Control",
@@ -785,10 +807,14 @@
"shift": "Shift",
"plus": "plus",
"hold": "Houd {{key}} ingedrukt",
"arrows": "Pijltoetsen",
"plusKey": "Plus-toets",
"minusKey": "Min-toets",
"noShortcut": "Geen sneltoets"
},
"visual": {
"hold": "Houd {{key}} ingedrukt"
"hold": "Houd {{key}} ingedrukt",
"arrows": "↑ ↓ ← →"
}
},
"fullScreenWarning": {
@@ -102,6 +102,9 @@
},
"messageReceived": {
"label": "Bericht ontvangen"
},
"participantWaiting": {
"label": "Deelnemer in de wachtkamer"
}
}
},
+1
View File
@@ -16,6 +16,7 @@ const DEFAULT_STATE: State = {
[NotificationType.ParticipantJoined, true],
[NotificationType.HandRaised, true],
[NotificationType.MessageReceived, true],
[NotificationType.ParticipantWaiting, true],
])
),
soundNotificationVolume: 0.1,
+1
View File
@@ -150,6 +150,7 @@ backend:
# Frontend
LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
LIVEKIT_DEFAULT_VIDEO_CODEC: vp9
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
+2 -2
View File
@@ -74,7 +74,7 @@ spec:
value: meet
- name: MINIO_ROOT_PASSWORD
value: password
image: "minio/minio"
image: "quay.io/minio/minio"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9000
@@ -103,7 +103,7 @@ spec:
spec:
containers:
- name: mc
image: minio/mc
image: quay.io/minio/mc
command:
- /bin/sh
- -c
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.27
version: 0.0.28
+28 -16
View File
@@ -81,18 +81,24 @@
| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` |
| `backend.createsuperuser.command` | backend migrate command | `["/bin/sh","-c","while ! python manage.py check --database default > /dev/null 2>&1\ndo\n echo \"Database not ready\"\n sleep 2\ndone\necho \"Database is ready\"\n\npython manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD\n"]` |
| `backend.createsuperuser.restartPolicy` | backend migrate job restart policy | `Never` |
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` |
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__lbheartbeat__` |
| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` |
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `30` |
| `backend.probes.liveness.initialDelaySeconds` | Configure timeout for backend liveness probe | `30` |
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `undefined` |
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `undefined` |
| `backend.probes.liveness.periodSeconds` | Configure period for backend liveness probe | `30` |
| `backend.probes.liveness.timeoutSeconds` | Configure timeout for backend liveness probe | `5` |
| `backend.probes.liveness.failureThreshold` | Configure failure threshold for backend liveness probe | `undefined` |
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `/__lbheartbeat__` |
| `backend.probes.startup.targetPort` | Configure port for backend HTTP startup probe | `undefined` |
| `backend.probes.startup.initialDelaySeconds` | Configure initial delay for backend startup probe | `undefined` |
| `backend.probes.startup.initialDelaySeconds` | Configure timeout for backend startup probe | `undefined` |
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__lbheartbeat__` |
| `backend.probes.startup.periodSeconds` | Configure period for backend startup probe | `5` |
| `backend.probes.startup.timeoutSeconds` | Configure timeout for backend startup probe | `5` |
| `backend.probes.startup.failureThreshold` | Configure failure threshold for backend startup probe | `12` |
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__heartbeat__` |
| `backend.probes.readiness.targetPort` | Configure port for backend HTTP readiness probe | `undefined` |
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `30` |
| `backend.probes.readiness.initialDelaySeconds` | Configure timeout for backend readiness probe | `30` |
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `undefined` |
| `backend.probes.readiness.periodSeconds` | Configure period for backend readiness probe | `30` |
| `backend.probes.readiness.timeoutSeconds` | Configure timeout for backend readiness probe | `5` |
| `backend.probes.readiness.failureThreshold` | Configure failure threshold for backend readiness probe | `3` |
| `backend.resources` | Resource requirements for the backend container | `{}` |
| `backend.nodeSelector` | Node selector for the backend Pod | `{}` |
| `backend.tolerations` | Tolerations for the backend Pod | `[]` |
@@ -211,18 +217,24 @@
| `summary.service.port` | summary Service listening port | `80` |
| `summary.service.targetPort` | summary container listening port | `8000` |
| `summary.service.annotations` | Annotations to add to the summary Service | `{}` |
| `summary.probes.liveness.path` | Configure path for summary HTTP liveness probe | `/__heartbeat__` |
| `summary.probes.liveness.path` | Configure path for summary HTTP liveness probe | `/__lbheartbeat__` |
| `summary.probes.liveness.targetPort` | Configure port for summary HTTP liveness probe | `undefined` |
| `summary.probes.liveness.initialDelaySeconds` | Configure initial delay for summary liveness probe | `30` |
| `summary.probes.liveness.initialDelaySeconds` | Configure timeout for summary liveness probe | `30` |
| `summary.probes.startup.path` | Configure path for summary HTTP startup probe | `undefined` |
| `summary.probes.liveness.initialDelaySeconds` | Configure initial delay summary liveness probe | `undefined` |
| `summary.probes.liveness.periodSeconds` | Configure the period for the summary liveness probe | `30` |
| `summary.probes.liveness.timeoutSeconds` | Configure timeout for summary liveness probe | `5` |
| `summary.probes.liveness.failureThreshold` | Configure failure threshold for summary liveness probe | `undefined` |
| `summary.probes.startup.path` | Configure path for summary HTTP startup probe | `/__lbheartbeat__` |
| `summary.probes.startup.targetPort` | Configure port for summary HTTP startup probe | `undefined` |
| `summary.probes.startup.initialDelaySeconds` | Configure initial delay for summary startup probe | `undefined` |
| `summary.probes.startup.initialDelaySeconds` | Configure timeout for summary startup probe | `undefined` |
| `summary.probes.readiness.path` | Configure path for summary HTTP readiness probe | `/__lbheartbeat__` |
| `summary.probes.startup.periodSeconds` | Configure period for the summary startup probe | `5` |
| `summary.probes.startup.timeoutSeconds` | Configure timeout for summary startup probe | `5` |
| `summary.probes.startup.failureThreshold` | Configure failure threshold for summary startup probe | `12` |
| `summary.probes.readiness.path` | Configure path for summary HTTP readiness probe | `/__heartbeat__` |
| `summary.probes.readiness.targetPort` | Configure port for summary HTTP readiness probe | `undefined` |
| `summary.probes.readiness.initialDelaySeconds` | Configure initial delay for summary readiness probe | `30` |
| `summary.probes.readiness.initialDelaySeconds` | Configure timeout for summary readiness probe | `30` |
| `summary.probes.readiness.initialDelaySeconds` | Configure initial delay for summary readiness probe | `undefined` |
| `summary.probes.readiness.periodSeconds` | Configure period for the summary readiness probe | `30` |
| `summary.probes.readiness.timeoutSeconds` | Configure timeout for summary readiness probe | `5` |
| `summary.probes.readiness.failureThreshold` | Configure failure threshold for summary readiness probe | `3` |
| `summary.resources` | Resource requirements for the summary container | `{}` |
| `summary.nodeSelector` | Node selector for the summary Pod | `{}` |
| `summary.tolerations` | Tolerations for the summary Pod | `[]` |
+3
View File
@@ -128,6 +128,9 @@ httpGet:
{{- end }}
initialDelaySeconds: {{ .initialDelaySeconds | eq nil | ternary 0 .initialDelaySeconds }}
timeoutSeconds: {{ .timeoutSeconds | eq nil | ternary 1 .timeoutSeconds }}
periodSeconds: {{ .periodSeconds | eq nil | ternary 10 .periodSeconds }}
failureThreshold: {{ .failureThreshold | eq nil | ternary 3 .failureThreshold }}
successThreshold: {{ .successThreshold | eq nil | ternary 1 .successThreshold }}
{{- end }}
{{/*
+31 -10
View File
@@ -320,26 +320,36 @@ backend:
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
## @param backend.probes.liveness.periodSeconds [nullable] Configure period for backend liveness probe
## @param backend.probes.liveness.timeoutSeconds [nullable] Configure timeout for backend liveness probe
## @param backend.probes.liveness.failureThreshold [nullable] Configure failure threshold for backend liveness probe
## @param backend.probes.startup.path [nullable] Configure path for backend HTTP startup probe
## @param backend.probes.startup.targetPort [nullable] Configure port for backend HTTP startup probe
## @param backend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for backend startup probe
## @param backend.probes.startup.periodSeconds [nullable] Configure period for backend startup probe
## @param backend.probes.startup.timeoutSeconds [nullable] Configure timeout for backend startup probe
## @param backend.probes.startup.failureThreshold [nullable] Configure failure threshold for backend startup probe
## @param backend.probes.readiness.path [nullable] Configure path for backend HTTP readiness probe
## @param backend.probes.readiness.targetPort [nullable] Configure port for backend HTTP readiness probe
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for backend readiness probe
## @param backend.probes.readiness.periodSeconds [nullable] Configure period for backend readiness probe
## @param backend.probes.readiness.timeoutSeconds [nullable] Configure timeout for backend readiness probe
## @param backend.probes.readiness.failureThreshold [nullable] Configure failure threshold for backend readiness probe
probes:
startup:
path: /__lbheartbeat__
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 12
liveness:
path: /__heartbeat__
initialDelaySeconds: 5
path: /__lbheartbeat__
periodSeconds: 30
timeoutSeconds: 5
readiness:
path: /__lbheartbeat__
initialDelaySeconds: 5
path: /__heartbeat__
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
## @param backend.resources Resource requirements for the backend container
resources: {}
@@ -623,25 +633,36 @@ summary:
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure initial delay summary liveness probe
## @param summary.probes.liveness.periodSeconds [nullable] Configure the period for the summary liveness probe
## @param summary.probes.liveness.timeoutSeconds [nullable] Configure timeout for summary liveness probe
## @param summary.probes.liveness.failureThreshold [nullable] Configure failure threshold for summary liveness probe
## @param summary.probes.startup.path [nullable] Configure path for summary HTTP startup probe
## @param summary.probes.startup.targetPort [nullable] Configure port for summary HTTP startup probe
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure initial delay for summary startup probe
## @param summary.probes.startup.periodSeconds [nullable] Configure period for the summary startup probe
## @param summary.probes.startup.timeoutSeconds [nullable] Configure timeout for summary startup probe
## @param summary.probes.startup.failureThreshold [nullable] Configure failure threshold for summary startup probe
## @param summary.probes.readiness.path [nullable] Configure path for summary HTTP readiness probe
## @param summary.probes.readiness.targetPort [nullable] Configure port for summary HTTP readiness probe
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for summary readiness probe
## @param summary.probes.readiness.periodSeconds [nullable] Configure period for the summary readiness probe
## @param summary.probes.readiness.timeoutSeconds [nullable] Configure timeout for summary readiness probe
## @param summary.probes.readiness.failureThreshold [nullable] Configure failure threshold for summary readiness probe
probes:
liveness:
path: /__heartbeat__
initialDelaySeconds: 5
periodSeconds: 30
readiness:
startup:
path: /__lbheartbeat__
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 12
liveness:
path: /__lbheartbeat__
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 5
readiness:
path: /__heartbeat__
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
## @param summary.resources Resource requirements for the summary container

Some files were not shown because too many files have changed in this diff Show More