Compare commits

...

32 Commits

Author SHA1 Message Date
lebaudantoine 7fbcbc89ed 🧑‍💻(devx) prototype OrbStack as local Kubernetes provider on macOS
Add a prototype setup that uses OrbStack as the local Kubernetes
provider on macOS, aiming to save a few GB of RAM compared to the
current stack.

The script has been tested locally but was mostly built through vibe
coding, so it has not been thoroughly reviewed yet.

Follow-ups to look into:

* Simplify the ingress and CoreDNS setup if possible.
* Wire up a way to run tests and lint against the Tilt stack.
2026-07-31 14:18:37 +02:00
lebaudantoine 89f8480e0b 🚸(frontend) show two initials in the Avatar when possible
Display two initials in the Avatar whenever the participant's name
allows it, instead of a single letter.

A single initial makes it too hard to distinguish participants when
their cameras are off, especially in larger
2026-07-29 00:00:35 +02:00
lebaudantoine d9bf6efa2a 💄(frontend) improve participant name rendering in the list
Rework how the participant name is displayed in the participant
list to show as much of the name as possible before truncating.

When the name has to be truncated, add a tooltip so users can hover
to see the full name.

Requested by users.
2026-07-29 00:00:35 +02:00
lebaudantoine 4cb7412194 (frontend) introduce an "unauthenticated" participant badge
Add a visual badge on participants who are not authenticated, so it
is immediately clear who could be an anonymous participant. This is
a small but explicit security signal in the participant list.

Beyond that, the badge also plays a functional role: since only
authenticated participants can be promoted or demoted, the badge
helps users see at a glance who is eligible for a role change.
2026-07-29 00:00:35 +02:00
lebaudantoine e8df597055 (frontend) notify user when their meeting role changes
Show a notification to the user whenever their role in the meeting
changes, so they immediately see when they have been promoted or
demoted.
2026-07-29 00:00:35 +02:00
lebaudantoine 05dfcd11ca 🐛(frontend) fall back to user.full_name on request-entry
Since the username refactoring, the username in the store could be
undefined when the join input was pre-filled from user.full_name,
because no keystroke was needed to populate the store.

This led to a 400 error on the request-entry endpoint whenever the
user joined without editing the pre-filled name.

Fall back to user.full_name when the store username is missing, so
the endpoint always receives a value.

Acknowledged as a somewhat wobbly fix, but ships as-is until the
underlying flow is reworked.
2026-07-29 00:00:35 +02:00
lebaudantoine b018832fcf (frontend) close admin side panel when the user is demoted
Listen to role changes in the admin panel and close the side panel
if the current user is demoted while it is open. Without this,
unprivileged users could still see the admin side panel until they
closed it manually.

I checked the other features that could be affected by hot role
changes; this was the only one still exposing admin-only UI after a
demotion. Everything else already handles live permission updates
correctly.
2026-07-29 00:00:35 +02:00
lebaudantoine a269160f6f (frontend) allow promoting authenticated participants
Introduce a new feature that lets a user promote one of the
authenticated participants of the meeting to a role with additional
privileges.

Known limitations:

* Only authenticated participants can be promoted, but there is no
  visual indicator yet distinguishing authenticated from anonymous
  participants. This will be added in a follow-up commit.
* The resource_access data fetched in the initial API call becomes
  stale after a promotion. It is not currently used in the product,
  so this is not visible, but it should either be refreshed later
  or removed from the initial fetch.
* Demoting a promoted user turns them into a member, which is still
  a privileged role. This is a deliberate choice until we introduce
  finer-grained tuning of participant roles.
2026-07-29 00:00:35 +02:00
lebaudantoine c3afa84d9b ♻️(frontend) extract closeSidePanel action at the store level
Extract the logic that closes the side panel into a utility function
declared at the store module level, as recommended by Valtio.

This avoids re-creating the function on every render and prevents
extra re-renders in components that use it.
2026-07-29 00:00:35 +02:00
lebaudantoine 8c6752a29f 🐛(backend) allow any string as sub in the API serializer
The API serializer was too restrictive on the `sub` field, expecting
a UUID. This worked in our development and production setups because
our Keycloak is configured to emit UUID subs, but it broke for other
providers.

Per the OIDC spec and the DB model, `sub` can be any string. Align
the serializer with this and accept arbitrary string values.

Fixes #1525.
2026-07-29 00:00:35 +02:00
lebaudantoine 4c57432a03 💄(frontend) render Avatar initials in uppercase
Uppercase the initials rendered in the Avatar so their vertical
centering stays consistent.

With lowercase letters, the initials were slightly shifted toward
the bottom of the Avatar, which broke the alignment.
2026-07-29 00:00:35 +02:00
lebaudantoine 3b7bfd999c 🔥(frontend) remove leftover console.count call
Drop a stray console.count call that was accidentally committed in a
previous PR and had been left in the codebase.
2026-07-29 00:00:35 +02:00
lebaudantoine 7f386b2e2f ♻️(frontend) derive is_administrable from participant metadata
The is_administrable flag was previously read from the room API
response through the room serializer, giving the frontend static
information about the user's rights.

Refactor the frontend so it derives this flag from the participant
role carried in the participant metadata instead.

Two benefits:

* The flag now updates live along with the participant
  attributes/metadata, so role changes are reflected immediately.
* It removes the duplication between the API response and the
  metadata, which both used to determine the user's capabilities.
2026-07-29 00:00:35 +02:00
lebaudantoine c55d8235fd (frontend) add client for the update participant role endpoint
Add the frontend client that calls the update participant role
endpoint. Straightforward API call, no special handling.
2026-07-29 00:00:35 +02:00
lebaudantoine c7b23abd68 (backend) expose is_authenticated in the LiveKit token
Include the is_authenticated flag on the user in the LiveKit token
and participant metadata.

The frontend needs this information (used in the next commit) to
know whether it can offer to promote a user with access to the room
admin.
2026-07-29 00:00:35 +02:00
lebaudantoine 5a641a4366 ♻️(backend) pass the participant role in the LiveKit token
The backend previously passed an abstract is_admin_or_owner boolean
flag in the LiveKit token. That kept the frontend minimalistic and
saved it from having to handle role comparisons.

As we introduce more features that need to distinguish between the
room owner and admins, refactor the token to carry the role
directly. The frontend can then derive the relevant flags from a
richer piece of information.
2026-07-29 00:00:35 +02:00
lebaudantoine f043ad6f98 (backend) add endpoint to update a participant role during a meeting
Add an endpoint that allows updating a user's role while in a
meeting. The goal is to let users promote other connected
participants to admin or moderator, so the burden of administrating
a meeting can be shared.
2026-07-29 00:00:35 +02:00
lebaudantoine 1141c1cecd ️(backend) add permission class checking the user is in the call
Introduce a new permission class that verifies the caller making a
request is both authenticated and actually present in the call.

It will be used to gate actions that require the user to be live in
the room, for example:

* allowing someone in from the waiting room
* promoting another participant to a different role

More generally, this covers every action where, for security
reasons, we need to make sure the user is truly present in the call
and that someone is not reusing their cookie as an API key.
2026-07-29 00:00:35 +02:00
Arnaud Robin 33792b050a 📝(legal) update terms of service
Update terms of service content after legal review
2026-07-28 13:52:50 +02:00
leo f4569c64e5 🐛(backend) preserve recording metadata when updating room access
Updating room access rewrote the entire metadata payload, removing information
about active recordings. This caused the frontend to lose track of ongoing
recordings and could trigger 409 errors when attempting to start a new
recording.

Consolidate the duplicated metadata update logic into
`RoomManagement.update_metadata()` and preserve merge behavior instead of
overwriting the full metadata object.
2026-07-28 10:54:03 +02:00
snyk-bot 6a00d3d087 ⬆️(frontend) upgrade i18next from 26.3.2 to 26.3.4
Snyk has created this PR to upgrade i18next from 26.3.2 to 26.3.4.

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-07-27 18:21:10 +02:00
Camille Moulin 2e975e2643 📝(metadata): Add publiccode.yml file
Recommended for Public Administration Open Source software projects.
See https://yml.publiccode.tools/

Signed-off-by: Camille Moulin <camille.moulin@numerique.gouv.fr>
2026-07-27 18:20:34 +02:00
lebaudantoine 4c63aa827f 📝(frontend) add changelog entry for PR #1510
Document in the CHANGELOG the set of changes shipped in PR #1510,
which groups the recent chat, layout and participant tile render
optimizations.
2026-07-24 18:31:47 +02:00
lebaudantoine 67e9bf2fef 🐛(frontend) reset chat state when the ChatProvider mounts
Reset the chat state on the first render of the ChatProvider, to
make sure no chat messages from a previous room leak into the new
one.

This covers SPA navigations where the user switches from one room
to another without a full page reload.
2026-07-24 18:31:47 +02:00
lebaudantoine 7124167947 🐛(frontend) fix pinnedTrackRef always evaluating to true
pinnedTrackRef was always truthy when evaluated in this
code path.
2026-07-24 18:31:47 +02:00
lebaudantoine 759388c72f ️(frontend) tripwire promotion of off-screen active speakers
Introduce a tripwire component that listens to
RoomEvent.ActiveSpeakersChanged imperatively and forces a single
re-render of its host only when an active speaker has none of their
tiles within the visible span (maxVisibleTiles). That re-render
re-runs useVisualStableUpdate, which reads live isSpeaking state
and performs the actual tile swap.

Speakers already visible are ignored, so this costs zero React work
in the common case.

This lets us drop the ActiveSpeakersChanged subscription from
useTracks in the StageLayout upstream (updateOnlyOn: []), which was
re-rendering the whole stage on every speaker change.
2026-07-24 18:31:47 +02:00
lebaudantoine a663b4dc76 ️(frontend) memoize the EffectsButton
Memoize the EffectsButton so it does not re-render on unrelated
parent updates when its props have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 73aa162dc8 ️(frontend) reduce re-renders of the ParticipantTile focus overlay
Optimize the idle mouse handling and the FocusOverlay component so
they no longer trigger frequent re-renders of the ParticipantTile
focus.

State related to hover and focus is now scoped closer to where it
is used, keeping updates local instead of propagating up the tile.
2026-07-24 18:31:47 +02:00
lebaudantoine a3851842e9 🚚(frontend) extract ParticipantTile sub-components into their own files
Split the sub-components currently declared inside ParticipantTile
into dedicated files.

This makes the ParticipantTile file easier to read and lets each
sub-component be imported and reasoned about on its own.
2026-07-24 18:31:47 +02:00
lebaudantoine 77964c6a74 ♻️(frontend) harmonize participant name handling in ParticipantTile
Align how the participant name is retrieved and rendered inside the
ParticipantTile, so the different code paths use a single consistent
approach instead of a mix of ad hoc logic.
2026-07-24 18:31:47 +02:00
lebaudantoine 72b863e794 ️(frontend) memoize the Placeholder component
Turn the Placeholder into a pure leaf component and memoize it, so
it does not re-render on unrelated parent updates when its props
have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 0e3c978af2 ️(frontend) size the Avatar with CSS instead of useSize
Stop using the useSize hook to compute the Avatar size in JS. Rely
on CSS to size the Avatar responsively instead.

This removes a set of unnecessary re-renders triggered by the
useSize subscription every time the container resized.
2026-07-24 18:31:47 +02:00
82 changed files with 2042 additions and 675 deletions
+18 -8
View File
@@ -12,20 +12,30 @@ and this project adheres to
- ✨(summary) report exception type in failure analytics - ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item - ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
- ⚡️(frontend) limit unnecessary re-renders #1510
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
## Fixed ## Fixed
- 🐛(transcription) fix silent bug in speaker assignment - 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic - 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent - 🐛(summary) properly detect when failure webhook should be sent
- 🐛(backend) preserve recording metadata when updating room access
### Changed - 🐛(backend) allow any string as sub in the API serializer
- 🐛(frontend) fall back to user.full_name on request-entry
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35 - 🚸(frontend) show two initials in the Avatar when possible
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
## [1.24.0] - 2026-07-21 ## [1.24.0] - 2026-07-21
+6
View File
@@ -389,6 +389,12 @@ build-k8s-cluster: \
./bin/start-kind.sh ./bin/start-kind.sh
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
build-k8s-cluster-orbstack: \
env.d/development/kube-secret
./bin/start-orbstack.sh
.PHONY: build-k8s-cluster-orbstack
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
+6
View File
@@ -1,5 +1,11 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location') load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject') load('ext://namespace', 'namespace_create', 'namespace_inject')
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
# a no-op for kind and a safety net for older Tilt versions.
allow_k8s_contexts('orbstack')
namespace_create('meet') namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak') DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
#
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
# cluster (macOS) instead of kind.
#
# This replicates what bin/start-kind.sh (numerique-gouv/tools
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
# - no kind cluster: OrbStack ships a lightweight single-node cluster
# - no local registry (kind-registry): OrbStack's cluster shares the
# Docker image store, so images built by Tilt are directly visible
# to pods. Tilt detects the "orbstack" context as a local cluster
# and skips pushing images entirely.
#
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
set -o errexit
APPLICATION=${1:-meet}
CONTEXT="orbstack"
echo "0. Check OrbStack Kubernetes is available"
if ! command -v mkcert >/dev/null 2>&1; then
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
exit 1
fi
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
if command -v orb >/dev/null 2>&1; then
orb start k8s
else
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
exit 1
fi
fi
kubectl config use-context "${CONTEXT}"
echo "0b. Check ports 80/443 are free on localhost"
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
# cluster is still running, its docker proxy already holds 80/443.
# Skip the check if ingress-nginx is already installed here: in that case
# the listener on 80/443 is our own LoadBalancer.
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
for port in 80 443; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "❌ Port ${port} is already in use on the host."
echo " If the kind cluster is running, delete it first:"
echo " kind delete cluster --name suite"
exit 1
fi
done
fi
echo "1. Create ca"
CURRENT_DIR=$(pwd)
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd "${CURRENT_DIR}"
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
# guarded individually so the script is safe to re-run after a partial
# failure (unlike the upstream kind script, which guards the whole block
# on namespace existence).
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
# (there is no registry on OrbStack).
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
fi
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
# The meet charts render Ingresses without ingressClassName. The kind
# provider manifest handles this via --watch-ingress-without-class=true;
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
]'
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
]'
fi
cat <<EOF | kubectl apply -n ingress-nginx -f -
apiVersion: v1
data:
allow-snippet-annotations: "true"
annotations-risk-level: Critical
custom-http-errors: 500,501,502,503,504
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
EOF
echo "2b. Wait for the ingress controller to be ready"
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
# Rewrite these names to the ingress-nginx service, like the kind setup does.
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
>/tmp/Corefile.orbstack
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
kubectl -n kube-system rollout restart deployments/coredns
fi
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
echo "4. Setup namespace"
kubectl create ns "${APPLICATION}"
fi
kubectl config set-context --current --namespace="${APPLICATION}"
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
echo "5. Inject our custom CA in a configmap for certifi"
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
fi
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
# Before Tilt deploys the app this returns the styled 404 from the default
# backend — that still proves LB -> controller works. 000 means the
# LoadBalancer is not bound to localhost.
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
if [ "${HTTP_CODE}" = "000" ]; then
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
else
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
fi
echo "6. Check pod readiness across all namespaces..."
sleep_interval=10
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
sleep $((sleep_interval * 2))
check_pods_ready() {
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts - Checking pod status..."
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
if [ "$not_ready_count" -eq 0 ]; then
echo "✅ All pods are ready!"
return 0
else
echo "$not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
sleep $sleep_interval
((attempt++))
fi
done
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
echo "Final pod status:"
kubectl get po -A
return 1
}
if check_pods_ready; then
echo "🎉 Cluster is fully ready!"
else
echo "⚠️ Some pods may need manual intervention"
exit 1
fi
+21
View File
@@ -143,3 +143,24 @@ $ make start-tilt-keycloak
``` ```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/). Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
### Alternative: OrbStack's built-in Kubernetes (macOS)
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
```shellscript
$ make build-k8s-cluster-orbstack
```
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
```shellscript
$ make start-tilt-keycloak
```
Notes:
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
+52
View File
@@ -0,0 +1,52 @@
publiccodeYmlVersion: 0.5.0
name: LaSuite Meet
applicationSuite: LaSuite
url: https://github.com/suitenumerique/meet
releaseDate: 2026-07-22
platforms:
- web
organisation:
name: DINUM
uri: https://numerique.gouv.fr
fundedBy:
- name: Direction interministérielle du numérique (DINUM)
uri: https://www.numerique.gouv.fr
developmentStatus: stable
softwareType: standalone/web
intendedAudience:
countries:
- FR
description:
en:
localisedName: LaSuite Meet
shortDescription: "Open Source video conference solution, based on LiveKit"
longDescription: "Open Source video conference application, based on LiveKit,
Django and React. It is the official web video conference application of
French Ministries."
features:
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- Meeting recording
- Meeting transcription & Summary
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
legal:
license: MIT
maintenance:
type: internal
contacts:
- name: "Samuel Paccoud"
email: samuel.paccoud@numerique.gouv.fr
affiliation: DINUM
- name: "Antoine Lebaud"
email: antoine.lebaud.ext@numerique.gouv.fr
affiliation: DINUM
localisation:
localisationReady: true
availableLanguages:
- fr
- de
- en
- nl
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "3.49.0",
"i18next": "^26.3.2", "i18next": "^26.3.4",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
@@ -9364,9 +9364,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.2", "version": "26.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==", "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
}, },
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "3.49.0",
"i18next": "26.3.2", "i18next": "26.3.4",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
+32
View File
@@ -6,6 +6,11 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from ..models import RoleChoices from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = { ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"} "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
# LiveKit token scoped to this room # LiveKit token scoped to this room
return request.auth.video.room == str(obj.id) return request.auth.video.room == str(obj.id)
class IsPresentInMeeting(permissions.BasePermission):
"""Check that the requesting user is currently connected to the meeting.
The requester must be session-authenticated (their DB identity is needed
to check privileges); presence is verified against LiveKit using their
`sub` as participant identity. Fails closed on LiveKit errors.
"""
message = "You must be connected to the meeting to perform this action."
def has_object_permission(self, request, view, obj):
"""Verify the requester's identity is a participant of the room."""
user = request.user
if not user or not user.is_authenticated:
return False
try:
return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+12 -5
View File
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
user=request.user, user=request.user,
username=username, username=username,
configuration=output["configuration"], configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner, role=role,
) )
else: else:
del output["pin_code"] del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
return output return output
@@ -299,8 +297,8 @@ class RoomInviteSerializer(serializers.Serializer):
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer): class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations.""" """Base serializer for participant management operations."""
participant_identity = serializers.UUIDField( participant_identity = serializers.CharField(
help_text="LiveKit participant identity (UUID format)" help_text="LiveKit participant identity (matching the user's sub format)"
) )
@@ -312,6 +310,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
) )
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
"""Validate an in-meeting role change (promotion/demotion) request."""
role = serializers.ChoiceField(
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
help_text="Target role. Ownership cannot be granted this way.",
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"] TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
+51
View File
@@ -88,6 +88,10 @@ from core.services.room_management import (
RoomManagementException, RoomManagementException,
RoomNotFoundException, RoomNotFoundException,
) )
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
@@ -639,6 +643,53 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK, status=drf_status.HTTP_200_OK,
) )
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant-role",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsPresentInMeeting,
],
)
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
"""Promote or demote a participant currently connected to the meeting.
Requires the requester to be session-authenticated, have privileges
(admin/owner) on the room, and be connected to the meeting.
If the target participant has a user account, the role is persisted
(`ResourceAccess`) then mirrored to their LiveKit attributes.
If the participant is anonymous, the promotion will fail.
"""
room = self.get_object()
serializer = serializers.ParticipantRoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
participant_identity = serializer.validated_data["participant_identity"]
role = serializer.validated_data["role"]
if str(request.user.sub) == str(participant_identity):
return drf_response.Response(
{"error": "You cannot change your own role."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
result = RoomRoleService().set_participant_role(
room=room,
participant_identity=participant_identity,
role=role,
actor=request.user,
)
except RoomRoleError as e:
return drf_response.Response({"error": str(e)}, status=e.status_code)
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
@decorators.action( @decorators.action(
detail=True, detail=True,
methods=["post"], methods=["post"],
@@ -9,6 +9,11 @@ from livekit import api
from core import models, utils from core import models, utils
from core.models import Recording from core.models import Recording
from core.recording.event.notification import notification_service from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -39,10 +44,15 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status) recording_status = status_mapping.get(egress_status)
if recording_status: if recording_status:
try: try:
utils.update_room_metadata( RoomManagement().update_metadata(
room_name, {"recording_status": recording_status} room_name, {"recording_status": recording_status}
) )
except utils.MetadataUpdateException as e: except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
@staticmethod @staticmethod
+12 -3
View File
@@ -2,8 +2,12 @@
import logging import logging
from core import utils
from core.models import Recording, RecordingStatusChoices from core.models import Recording, RecordingStatusChoices
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .exceptions import ( from .exceptions import (
RecordingStartError, RecordingStartError,
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode mode = recording.options.get("original_mode", None) or recording.mode
try: try:
utils.update_room_metadata( RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"} room_name, {"recording_mode": mode, "recording_status": "starting"}
) )
except utils.MetadataUpdateException as e: except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
logger.info( logger.info(
+14 -4
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api from livekit import api
from core import models, utils from core import models
from core.recording.services.metadata_collector import ( from core.recording.services.metadata_collector import (
MetadataCollectorException, MetadataCollectorException,
MetadataCollectorService, MetadataCollectorService,
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
) )
from .lobby import LobbyService from .lobby import LobbyService
from .room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .telephony import TelephonyException, TelephonyService from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -177,10 +182,15 @@ class LiveKitEventsService:
try: try:
room_name = str(recording.room.id) room_name = str(recording.room.id)
utils.update_room_metadata( RoomManagement().update_metadata(
room_name, {}, ["recording_mode", "recording_status"] room_name, remove_keys=["recording_mode", "recording_status"]
) )
except utils.MetadataUpdateException as e: except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
if recording.options.get("metadata_collector_dispatch_id", None) is not None: if recording.options.get("metadata_collector_dispatch_id", None) is not None:
+15 -5
View File
@@ -104,22 +104,31 @@ class LobbyService:
) )
@staticmethod @staticmethod
def can_bypass_lobby(room, user) -> bool: def can_bypass_lobby(room, user, role) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly. """Determines if a user can bypass the waiting lobby and join a room directly.
A user can bypass the lobby if: A user can bypass the lobby if:
1. The room is public (open to everyone) 1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated 2. The room has TRUSTED access level and the user is authenticated
2. The room has RESTRICTED access level and the user has any role
Note: Room access levels can change while participants are waiting in the lobby. Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting. participants who have already begun waiting.
""" """
return room.is_public or ( return (
room.is_public
or (
room.access_level == models.RoomAccessLevel.TRUSTED room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated and user.is_authenticated
) )
or (
room.access_level == models.RoomAccessLevel.RESTRICTED
and user.is_authenticated
and role is not None
)
)
def request_entry( def request_entry(
self, self,
@@ -144,8 +153,9 @@ class LobbyService:
participant = self._get_participant(room.id, participant_id) participant = self._get_participant(room.id, participant_id)
room_id = str(room.id) room_id = str(room.id)
user_role = room.get_role(request.user)
if self.can_bypass_lobby(room=room, user=request.user): if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
if participant is None: if participant is None:
participant = LobbyParticipant( participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED, status=LobbyParticipantStatus.ACCEPTED,
@@ -162,8 +172,8 @@ class LobbyService:
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
role=user_role,
) )
return participant, livekit_config return participant, livekit_config
@@ -183,8 +193,8 @@ class LobbyService:
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
role=user_role,
) )
return participant, livekit_config return participant, livekit_config
+29 -3
View File
@@ -8,6 +8,7 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from livekit.api import ( from livekit.api import (
ListRoomsRequest,
TwirpError, TwirpError,
UpdateRoomMetadataRequest, UpdateRoomMetadataRequest,
) )
@@ -29,20 +30,45 @@ class RoomManagement:
"""Service for managing LiveKit rooms.""" """Service for managing LiveKit rooms."""
@async_to_sync @async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None): async def update_metadata(
"""Update a LiveKit room's metadata. self,
room_name: str,
metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None,
):
"""Merge values into a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string). (i.e. the Room model's UUID as a string).
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the metadata update otherwise fails.
""" """
lkapi = utils.create_livekit_client() lkapi = utils.create_livekit_client()
try: try:
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
if not response.rooms:
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist")
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
for key in remove_keys or []:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **(metadata or {})}
await lkapi.room.update_room_metadata( await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest( UpdateRoomMetadataRequest(
room=room_name, room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "", metadata=json.dumps(updated_metadata),
) )
) )
+178
View File
@@ -0,0 +1,178 @@
"""Room role management service.
Single entry point for changing a user's role on a room, used by:
- the in-meeting endpoint (promote/demote a connected participant)
- (more to come soon)
`ResourceAccess` is the source of truth. The LiveKit `room_role`
participant attribute is only a projection of it, synced best-effort.
"""
from logging import getLogger
from uuid import UUID
from core import models
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
logger = getLogger(__name__)
class RoomRoleError(Exception):
"""Base exception for room role management errors."""
status_code = 400
class SelfActionError(RoomRoleError):
"""Raised when a user tries to change their own role."""
status_code = 403
class OwnerRoleError(RoomRoleError):
"""Raised when trying to demote an owner or grant ownership."""
status_code = 403
class ParticipantNotInMeetingError(RoomRoleError):
"""Raised when the target participant is not connected to the meeting."""
status_code = 404
class UserNotFoundError(RoomRoleError):
"""Raised when the target participant has no user account in database."""
status_code = 404
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
class RoomRoleService:
"""Manage promotion and demotion of room co-hosts."""
def set_role(
self, room: models.Room, user: models.User, role: str, actor: models.User
):
"""Persist `role` for `user` on `room`, idempotently and atomically.
Returns the up-to-date `ResourceAccess`. Never grants or removes
ownership: granting OWNER is refused, and an existing OWNER access
is never modified.
"""
if role not in ASSIGNABLE_ROLES:
raise OwnerRoleError("Ownership cannot be granted through this action.")
if actor is not None and user == actor:
raise SelfActionError("You cannot change your own role.")
access, created = models.ResourceAccess.objects.get_or_create(
resource=room,
user=user,
defaults={"role": role},
)
if created:
return access
if access.role == models.RoleChoices.OWNER:
raise OwnerRoleError("Room owners cannot be demoted.")
if access.role != role:
access.role = role
access.save(update_fields=["role", "updated_at"])
return access
def set_participant_role(
self,
room: models.Room,
participant_identity: UUID,
role: str,
actor: models.User,
):
"""Change the role of a participant currently connected to the meeting.
- The participant must be connected (checked against LiveKit).
- The participant must map to a user account.
- The role is persisted in DB then mirrored to LiveKit.
Returns a dict: {"role", "livekit_synced"}.
"""
room_name = str(room.pk)
participants_management = ParticipantsManagement()
try:
is_in_meeting = participants_management.check_if_in_meeting(
room_name=room_name, identity=str(participant_identity)
)
except ParticipantNotFoundException as e:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
) from e
if not is_in_meeting:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
)
user = models.User.objects.filter(sub=participant_identity).first()
if user is None:
raise UserNotFoundError(
"This participant has no user account and cannot be assigned a role."
)
# Source of truth first: even if the LiveKit sync below fails,
# the role is real and any fresh token will carry it.
self.set_role(room=room, user=user, role=role, actor=actor)
livekit_synced = self._sync_livekit_role(
room_name=room_name,
participant_identity=str(participant_identity),
role=str(role),
)
return {
"role": role,
"livekit_synced": livekit_synced,
}
@staticmethod
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
"""Mirror the role to the participant's LiveKit attributes.
Best-effort: returns False on failure instead of raising, so callers
can report a partial success. Re-running the action re-syncs.
"""
try:
ParticipantsManagement().update(
room_name=room_name,
identity=participant_identity,
attributes={"room_role": role},
)
except ParticipantNotFoundException:
# The participant left between the presence check and the update:
# harmless, the DB state (if any) remains authoritative.
logger.info(
"Participant %s left room %s before role sync",
participant_identity,
room_name,
)
return False
except ParticipantsManagementException:
logger.exception(
"Could not sync role to LiveKit for participant %s in room %s",
participant_identity,
room_name,
)
return False
return True
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service) return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_success( def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
mock_update_room_metadata, mediator, mock_worker_service
):
"""Test successful recording start""" """Test successful recording start"""
# Setup # Setup
worker_id = "test-worker-123" worker_id = "test-worker-123"
@@ -60,7 +58,7 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(mock_recording.room.id), str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"}, {"recording_mode": mock_recording.mode, "recording_status": "starting"},
) )
@@ -69,9 +67,9 @@ def test_start_recording_success(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError] "error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_worker_errors( def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class mock_update_metadata, mediator, mock_worker_service, error_class
): ):
"""Test handling of various worker errors during start""" """Test handling of various worker errors during start"""
# Setup # Setup
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED, RecordingStatusChoices.ABORTED,
], ],
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_from_forbidden_status( def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status mock_update_metadata, mediator, mock_worker_service, status
): ):
"""Test handling of various worker errors during start""" """Test handling of various worker errors during start"""
# Setup # Setup
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db() mock_recording.refresh_from_db()
assert mock_recording.status == status assert mock_recording.status == status
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service): def test_mediator_stop_recording_success(mediator, mock_worker_service):
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
room = RoomFactory() room = RoomFactory()
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
other_room = RoomFactory() other_room = RoomFactory()
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post( response = client.post(
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
room = RoomFactory(configuration={"everyone_can_mute": False}) room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
) )
# Token identity matches the admin user so LiveKitTokenAuthentication # Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin. # resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
# Token is scoped to a DIFFERENT room, and admin status must only be # Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit # honored when established via session, never via a LiveKit
# token, which can be replayed off-host. # token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True) token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post( response = client.post(
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
role=random.choice(["administrator", "owner"]), role=random.choice(["administrator", "owner"]),
) )
# The token is the only credential. # The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True) token = utils.generate_token(str(room.id), admin_user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
room = RoomFactory() room = RoomFactory()
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
) )
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
) )
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
) )
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"} payload = {"participant_identity": ["test"]}
url = reverse("rooms-update-participant", kwargs={"pk": room.id}) url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json") response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data) assert "Not a valid string." in str(response.data)
def test_update_participant_no_update_fields(): def test_update_participant_no_update_fields():
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"} payload = {"participant_identity": ["invalid-uuid"]}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id}) url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json") response = client.post(url, payload, format="json")
@@ -12,7 +12,7 @@ import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel from ...models import RoleChoices, RoomAccessLevel
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"configuration": {}, "configuration": {},
"access_level": "trusted", "access_level": "trusted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"configuration": {}, "configuration": {},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -278,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
username=None, username=None,
color=None, color=None,
sources=["camera"], sources=["camera"],
is_admin_or_owner=False, role=None,
participant_id=None, participant_id=None,
) )
@@ -313,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"configuration": {}, "configuration": {},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -330,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
username=None, username=None,
color=None, color=None,
sources=None, sources=None,
is_admin_or_owner=False, role=None,
participant_id=None, participant_id=None,
) )
@@ -355,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -401,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -418,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
username=None, username=None,
color=None, color=None,
sources=["camera"], sources=["camera"],
is_admin_or_owner=False, role=str(RoleChoices.MEMBER),
participant_id=None, participant_id=None,
) )
@@ -493,7 +483,6 @@ def test_api_rooms_retrieve_administrators(
assert content_dict == { assert content_dict == {
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": True,
"configuration": {}, "configuration": {},
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
@@ -511,6 +500,6 @@ def test_api_rooms_retrieve_administrators(
username=None, username=None,
color=None, color=None,
sources=None, sources=None,
is_admin_or_owner=True, role=str(user_access.role),
participant_id=None, participant_id=None,
) )
@@ -0,0 +1,319 @@
"""
Test rooms API endpoints in the Meet core app: update-participant-role.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import ResourceAccess, RoleChoices
from ...services.participants_management import ParticipantNotFoundException
pytestmark = pytest.mark.django_db
def test_update_participant_role_anonymous():
"""Anonymous requesters are rejected."""
client = APIClient()
room = RoomFactory()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 401
def test_update_participant_role_requires_privileges():
"""A simple member cannot promote other participants."""
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
"""An admin who is not connected to the meeting is rejected."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
room_name=str(room.pk), identity=str(user.sub)
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_target_self(mock_perm_pm):
"""Requesters cannot change their own role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
user = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": user.sub, "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "You cannot change your own role."}
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_promotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting a connected, authenticated participant persists the role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": True,
}
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.ADMIN
mock_sync.assert_called_once_with(
room_name=str(room.pk),
participant_identity=str(target.sub),
role="administrator",
)
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_demotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Demoting a connected admin back to member updates the access row."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "member"},
format="json",
)
assert response.status_code == 200
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.MEMBER
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_demote_owner(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Room owners can never be demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
admin = UserFactory()
owner = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(owner.sub), "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "Room owners cannot be demoted."}
assert (
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
)
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_anonymous_target_is_ephemeral(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an anonymous participant should not be possible."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
anonymous_identity = uuid.uuid4()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": anonymous_identity, "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert response.json() == {
"error": "This participant has no user account and cannot be assigned a role."
}
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_target_not_in_meeting(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Only connected participants can be promoted or demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
ParticipantNotFoundException("Participant does not exist")
)
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_is_idempotent_and_resyncs(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
mock_sync.assert_called_once()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_livekit_failure_reports_partial_success(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""A LiveKit sync failure does not lose the persisted role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = False
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": False,
}
assert (
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
"""The owner role can never be granted through this endpoint."""
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "owner"},
format="json",
)
assert response.status_code == 400
@@ -20,8 +20,9 @@ from core.services.livekit_events import (
api, api,
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.telephony import TelephonyException, TelephonyService from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -70,9 +71,9 @@ def test_initialization(
), ),
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service
): ):
"""Should successfully stop recording and notifies all participant.""" """Should successfully stop recording and notifies all participant."""
@@ -86,8 +87,8 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type} room_name=str(recording.room.id), notification_data={"type": notification_type}
) )
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
recording.refresh_from_db() recording.refresh_from_db()
@@ -104,9 +105,9 @@ def test_handle_egress_ended_success(
(EgressStatus.EGRESS_ABORTED, "aborted"), (EgressStatus.EGRESS_ABORTED, "aborted"),
), ),
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_success( def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service mock_update_metadata, egress_status, status, service
): ):
"""Should successfully update room's metadata.""" """Should successfully update room's metadata."""
@@ -117,7 +118,7 @@ def test_handle_egress_updated_success(
service._handle_egress_updated(mock_data) service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status} str(recording.room.id), {"recording_status": status}
) )
@@ -129,9 +130,9 @@ def test_handle_egress_updated_success(
EgressStatus.EGRESS_LIMIT_REACHED, EgressStatus.EGRESS_LIMIT_REACHED,
), ),
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled( def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service mock_update_metadata, egress_status, service
): ):
"""Should ignore certain egress status and don't trigger metadata updates.""" """Should ignore certain egress status and don't trigger metadata updates."""
@@ -142,7 +143,7 @@ def test_handle_egress_updated_non_handled(
service._handle_egress_updated(mock_data) service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -153,9 +154,9 @@ def test_handle_egress_updated_non_handled(
), ),
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service
): ):
"""Should successfully stop and save recording when metadata's update fails.""" """Should successfully stop and save recording when metadata's update fails."""
@@ -164,7 +165,7 @@ def test_handle_egress_ended_metadata_update_fails(
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying") mock_update_metadata.side_effect = RoomManagementException("Error notifying")
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
@@ -178,9 +179,9 @@ def test_handle_egress_ended_metadata_update_fails(
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails( def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should raise ActionFailedError when notification fails but still stop recording.""" """Should raise ActionFailedError when notification fails but still stop recording."""
@@ -200,15 +201,15 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == "stopped" assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_found( def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should raise ActionFailedError when recording doesn't exist.""" """Should raise ActionFailedError when recording doesn't exist."""
@@ -223,16 +224,16 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
mock_notify.assert_not_called() mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == "active" assert recording.status == "active"
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_active( def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should ignore non-active recordings.""" """Should ignore non-active recordings."""
@@ -244,8 +245,8 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
mock_notify.assert_not_called() mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
recording.refresh_from_db() recording.refresh_from_db()
@@ -253,9 +254,9 @@ def test_handle_egress_ended_recording_not_active(
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_limit_reached( def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should ignore egress non-limit-reached statuses.""" """Should ignore egress non-limit-reached statuses."""
@@ -267,16 +268,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
mock_notify.assert_not_called() mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
assert recording.status == "stopped" assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService") @mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met( def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings mock_update_metadata, mock_collector_class, service, settings
): ):
"""Should call MetadataCollectorService.stop when it exists.""" """Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True settings.METADATA_COLLECTOR_ENABLED = True
@@ -306,7 +307,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
], ],
) )
@mock.patch("core.services.livekit_events.MetadataCollectorService") @mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met( def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings _, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments ): # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -335,7 +336,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_external_services" "notify_external_services"
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"egress_status", "egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED], [EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
@@ -345,7 +346,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
[(True, "notification_succeeded"), (False, "saved")], [(True, "notification_succeeded"), (False, "saved")],
) )
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913 def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata, mock_update_metadata,
mock_notify, mock_notify,
mock_notify_external_services, mock_notify_external_services,
notify_return_value, notify_return_value,
@@ -378,7 +379,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
"notify_external_services" "notify_external_services"
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"egress_status, expected_status", "egress_status, expected_status",
[ [
@@ -387,7 +388,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
], ],
) )
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913 def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata, mock_update_metadata,
mock_notify, mock_notify,
mock_notify_external_services, mock_notify_external_services,
egress_status, egress_status,
@@ -424,9 +425,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
EgressStatus.EGRESS_ABORTED, EgressStatus.EGRESS_ABORTED,
], ],
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status( def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings mock_update_metadata, egress_status, service, settings
): ):
"""Shouldn't save on invalid status.""" """Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False settings.RECORDING_STORAGE_EVENT_ENABLE = False
@@ -445,9 +446,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"] "status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording( def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings mock_update_metadata, status, service, settings
): ):
"""Should handle non-savable recordings idempotently without raising. """Should handle non-savable recordings idempotently without raising.
+72 -17
View File
@@ -9,13 +9,14 @@ import uuid
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponse from django.http import HttpResponse
import pytest import pytest
from core.factories import RoomFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoomAccessLevel from core.models import RoleChoices, RoomAccessLevel
from core.services.lobby import ( from core.services.lobby import (
LobbyParticipant, LobbyParticipant,
LobbyParticipantNotFound, LobbyParticipantNotFound,
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
def test_can_bypass_lobby_public_room(lobby_service): def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth.""" """Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
# Anonymous user # Anonymous user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = False user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is True assert lobby_service.can_bypass_lobby(room, user, role=None) is True
# Authenticated user # Authenticated user
user.is_authenticated = True user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service): def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
# Authenticated user # Authenticated user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = True user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service): def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
# Anonymous user # Anonymous user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = False user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False assert lobby_service.can_bypass_lobby(room, user, role=None) is False
def test_can_bypass_lobby_private_room(lobby_service): def test_can_bypass_lobby_private_room(lobby_service):
"""Should return False for private rooms regardless of user auth.""" """Should return False for private rooms regardless of user auth if role is not."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Anonymous user # Anonymous user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = False user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False assert lobby_service.can_bypass_lobby(room, user, role=None) is False
# Authenticated user # Authenticated user
user.is_authenticated = True user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is False assert lobby_service.can_bypass_lobby(room, user, role=None) is False
@pytest.mark.parametrize(
"role",
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
)
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
"""Should return True for private rooms if the user is authenticated and has any role."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
@mock.patch("core.utils.generate_livekit_config") @mock.patch("core.utils.generate_livekit_config")
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
): ):
"""Test requesting entry to a public room.""" """Test requesting entry to a public room."""
request = mock.Mock() request = mock.Mock()
request.user = mock.Mock() request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
): ):
"""Test requesting entry to a trusted room when the user is authenticated.""" """Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock() request = mock.Mock()
request.user = mock.Mock() request.user = UserFactory()
request.user.is_authenticated = True
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
"""Test requesting entry for a new participant.""" """Test requesting entry for a new participant."""
request = mock.Mock() request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
"""Test requesting entry for a waiting participant.""" """Test requesting entry for a waiting participant."""
request = mock.Mock() request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
): ):
"""Test requesting entry for an accepted participant.""" """Test requesting entry for an accepted participant."""
request = mock.Mock() request = mock.Mock()
request.user = mock.Mock() request.user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
username=username, username=username,
color="#123456", color="#123456",
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_participant_with_role(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry for a participant with a role on the room."""
request = mock.Mock()
request.user = UserFactory()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
participant_id="test-participant-id",
role="administrator",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
+11 -58
View File
@@ -31,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI, LiveKitAPI,
SendDataRequest, SendDataRequest,
TwirpError, TwirpError,
UpdateRoomMetadataRequest,
VideoGrants, VideoGrants,
) )
@@ -66,7 +65,7 @@ def generate_token(
username: Optional[str] = None, username: Optional[str] = None,
color: Optional[str] = None, color: Optional[str] = None,
sources: Optional[List[str]] = None, sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False, role: Optional[str] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
) -> str: ) -> str:
"""Generate a LiveKit access token for a user in a specific room. """Generate a LiveKit access token for a user in a specific room.
@@ -80,7 +79,7 @@ def generate_token(
If none, a value will be generated If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES. If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users; participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous. used as identity when user.is_anonymous.
@@ -88,6 +87,7 @@ def generate_token(
str: The LiveKit JWT access token. str: The LiveKit JWT access token.
""" """
is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner: if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
@@ -128,7 +128,11 @@ def generate_token(
.with_identity(identity) .with_identity(identity)
.with_name(display_name) .with_name(display_name)
.with_attributes( .with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"} {
"color": color,
"room_role": role,
"is_authenticated": "true" if user.is_authenticated else "false",
}
) )
) )
@@ -139,7 +143,7 @@ def generate_livekit_config(
room_id: str, room_id: str,
user, user,
username: str, username: str,
is_admin_or_owner: bool, role: Optional[str] = None,
color: Optional[str] = None, color: Optional[str] = None,
configuration: Optional[dict] = None, configuration: Optional[dict] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
@@ -150,7 +154,7 @@ def generate_livekit_config(
room_id: Room identifier room_id: Room identifier
user: User instance requesting access user: User instance requesting access
username: Display name in room username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room. role (str): Room's access role if any
color (Optional[str]): Optional color to associate with the participant. color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings. configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users; participant_id (Optional[str]): Stable identifier for anonymous users;
@@ -173,7 +177,7 @@ def generate_livekit_config(
username=username, username=username,
color=color, color=color,
sources=sources, sources=sources,
is_admin_or_owner=is_admin_or_owner, role=role,
participant_id=participant_id, participant_id=participant_id,
), ),
} }
@@ -258,57 +262,6 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose() await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
+4
View File
@@ -114,6 +114,10 @@ const config: Config = {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)', clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
}, },
}, },
overlayIn: {
from: { opacity: 0 },
to: { opacity: 0.6 },
},
}, },
tokens: defineTokens({ tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff. /* we take a few things from the panda preset but for now we clear out some stuff.
+12 -3
View File
@@ -28,6 +28,15 @@ const avatar = cva({
}, },
}) })
const getInitials = (name?: string): string => {
if (!name) return ''
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ''
const first = words[0].charAt(0)
const second = words.length > 1 ? words[1].charAt(0) : ''
return (first + second).toUpperCase()
}
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & { export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
name?: string name?: string
bgColor?: string bgColor?: string
@@ -35,7 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo( export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? '' const initials = getInitials(name)
return ( return (
<div <div
style={{ backgroundColor: bgColor, ...style }} style={{ backgroundColor: bgColor, ...style }}
@@ -52,11 +61,11 @@ export const Avatar = React.memo(
y="50" y="50"
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize="52" fontSize={initials.length > 1 ? 48 : 52}
fontWeight="500" fontWeight="500"
fill="currentColor" fill="currentColor"
> >
{initial} {initials.toUpperCase()}
</text> </text>
</svg> </svg>
</div> </div>
@@ -3,7 +3,7 @@ import { ref } from 'valtio'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useChat, useRoomContext } from '@livekit/components-react' import { useChat, useRoomContext } from '@livekit/components-react'
import { appendRow, chatStore } from '@/stores/chat' import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
import type { ChatMessage } from '@livekit/components-core' import type { ChatMessage } from '@livekit/components-core'
import { import {
LocalParticipant, LocalParticipant,
@@ -19,6 +19,10 @@ export const ChatProvider = () => {
const room = useRoomContext() const room = useRoomContext()
useEffect(() => {
resetChatStore()
}, [])
// Tigger the message notification (temporary) // Tigger the message notification (temporary)
useEffect(() => { useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking // TEMPORARY: This is a brittle workaround that relies on message count tracking
@@ -11,6 +11,7 @@ import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout' import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl' import { PaginationControl } from './PaginationControl'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
interface GridLayoutObserverProps { interface GridLayoutObserverProps {
gridEl: React.RefObject<HTMLDivElement> gridEl: React.RefObject<HTMLDivElement>
@@ -74,6 +75,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
[props] [props]
) )
const pagination = usePagination(maxTiles, tracks) const pagination = usePagination(maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSwipe(gridEl, { useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage, onLeftSwipe: pagination.nextPage,
@@ -9,7 +9,7 @@ import {
log, log,
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-core' } from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout' import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@@ -23,7 +23,7 @@ export const StageLayout = () => {
{ source: Track.Source.Camera, withPlaceholder: true }, { source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false }, { source: Track.Source.ScreenShare, withPlaceholder: false },
], ],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } { updateOnlyOn: [], onlySubscribed: false }
) )
const screenShareTracks = tracks const screenShareTracks = tracks
@@ -104,7 +104,7 @@ export const StageLayout = () => {
> >
<ParticipantTile /> <ParticipantTile />
</CarouselLayout> </CarouselLayout>
{pinnedTrackRef && <FocusLayout trackRef={pinnedTrackRef} />} <FocusLayout trackRef={pinnedTrackRef} />
</FocusLayoutContainer> </FocusLayoutContainer>
</div> </div>
)} )}
@@ -0,0 +1,48 @@
import { RoomEvent } from 'livekit-client'
import type { Participant } from 'livekit-client'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useReducer, useRef } from 'react'
/**
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* re-render of the host component only when an active speaker has none of
* their tiles are within the visible span (`maxVisibleTiles`). That render
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
* state and performs the actual swap.
*
* Speakers already visible are ignored. Everything else costs zero React work.
*
* Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
sortedTiles: TrackReferenceOrPlaceholder[]
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
)
if (hiddenSpeakerExists) {
forceRender()
}
}
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
}
}, [room])
}
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
} }
}, [room, triggerNotificationSoundIfRoomIsSmall]) }, [room, triggerNotificationSoundIfRoomIsSmall])
useEffect(() => {
const handleAttributeChanged = (
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant.isLocal || !('room_role' in changedAttributes)) return
const newRole = changedAttributes['room_role']
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: newRole,
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
return () => {
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
}
}, [room])
useEffect(() => { useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => { const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => { toastQueue.visibleToasts.forEach((toast) => {
@@ -14,4 +14,5 @@ export const NotificationDuration = {
RECORDING_SAVING: ToastDuration.EXTRA_LONG, RECORDING_SAVING: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT, REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG, RECORDING_REQUESTED: ToastDuration.LONG,
ROLE_CHANGED: ToastDuration.LONG,
} as const } as const
@@ -17,4 +17,5 @@ export enum NotificationType {
ScreenRecordingLimitReached = 'screenRecordingLimitReached', ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving', RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved', PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
} }
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved' import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest' import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom' import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
interface ToastRegionProps extends AriaToastRegionProps { interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData> state: ToastState<ToastData>
@@ -70,6 +71,9 @@ const renderToast = (
<ToastRecordingSaving key={toast.key} toast={toast} state={state} /> <ToastRecordingSaving key={toast.key} toast={toast} state={state} />
) )
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
default: default:
return <Toast key={toast.key} toast={toast} state={state} /> return <Toast key={toast.key} toast={toast} state={state} />
} }
@@ -0,0 +1,28 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
export function ToastRoleChanged({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'roleChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const newRole = t(`roles.${props.toast.content.newRole}`)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t('body', { role: newRole })}
</HStack>
</StyledToastContainer>
)
}
@@ -3,7 +3,6 @@ import {
LockLockedIcon, LockLockedIcon,
ScreenShareIcon, ScreenShareIcon,
useIsEncrypted, useIsEncrypted,
useParticipantInfo,
} from '@livekit/components-react' } from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
@@ -12,13 +11,14 @@ import { ParticipantName } from './ParticipantName'
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper' import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
export const ParticipantMetadata = ({ export const ParticipantMetadata = ({
displayedName,
participant, participant,
isScreenShare, isScreenShare,
}: { }: {
displayedName: string
participant: Participant participant: Participant
isScreenShare: boolean isScreenShare: boolean
}) => { }) => {
const { identity, name } = useParticipantInfo({ participant })
const isEncrypted = useIsEncrypted(participant) const isEncrypted = useIsEncrypted(participant)
return ( return (
@@ -42,7 +42,7 @@ export const ParticipantMetadata = ({
)} )}
<div className="lk-participant-name-wrapper"> <div className="lk-participant-name-wrapper">
<ParticipantName <ParticipantName
displayedName={name != '' ? name : identity} displayedName={displayedName}
isScreenShare={isScreenShare} isScreenShare={isScreenShare}
/> />
</div> </div>
@@ -1,10 +1,7 @@
import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient' import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import React, { useMemo } from 'react'
import { useMemo } from 'react'
const StyledParticipantPlaceHolder = styled('div', { const StyledParticipantPlaceHolder = styled('div', {
base: { base: {
@@ -23,39 +20,36 @@ const StyledAvatarWrapper = styled('div', {
aspectRatio: '1 / 1', aspectRatio: '1 / 1',
width: 'min(90cqmin, 160px)', width: 'min(90cqmin, 160px)',
fontSize: 'min(27cqmin, 48px)', fontSize: 'min(27cqmin, 48px)',
'[data-lk-speaking="true"] &': {
animation: 'pulse 1s infinite',
},
}, },
}) })
type ParticipantPlaceholderProps = { type ParticipantPlaceholderProps = {
participant: Participant color: string
displayedNamed: string
} }
export const ParticipantPlaceholder = ({ export const ParticipantPlaceholder = React.memo(
participant, ({ color, displayedNamed }: ParticipantPlaceholderProps) => {
}: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant)
const backgroundGradient = useMemo( const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(participantColor), () => getParticipantBackgroundGradient(color),
[participantColor] [color]
) )
return ( return (
<StyledParticipantPlaceHolder <StyledParticipantPlaceHolder
style={{ style={{
backgroundColor: participantColor, backgroundColor: color,
backgroundImage: backgroundGradient, backgroundImage: backgroundGradient,
}} }}
> >
<StyledAvatarWrapper <StyledAvatarWrapper>
style={{ animation: isSpeaking ? 'pulse 1s infinite' : undefined }} <Avatar name={displayedNamed} bgColor={color} context="placeholder" />
>
<Avatar
name={participant.name}
bgColor={participantColor}
context="placeholder"
/>
</StyledAvatarWrapper> </StyledAvatarWrapper>
</StyledParticipantPlaceHolder> </StyledParticipantPlaceHolder>
) )
} }
)
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
@@ -1,3 +1,4 @@
import React from 'react'
import { import {
AudioTrack, AudioTrack,
ParticipantTileProps, ParticipantTileProps,
@@ -8,8 +9,8 @@ import {
VideoTrack, VideoTrack,
TrackRefContext, TrackRefContext,
ParticipantContextIfNeeded, ParticipantContextIfNeeded,
useParticipantInfo,
} from '@livekit/components-react' } from '@livekit/components-react'
import React from 'react'
import { import {
isEqualTrackRef, isEqualTrackRef,
isTrackReference, isTrackReference,
@@ -17,15 +18,15 @@ import {
} from '@livekit/components-core' } from '@livekit/components-core'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus' import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning' import { FullScreenShareWarning } from './FullScreenShareWarning'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog' import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels' import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { KeyboardShortcutHint } from './KeyboardShortcutHint' import { KeyboardShortcutHint } from './KeyboardShortcutHint'
import { layoutStore, clearPinnedTrack } from '@/stores/layout' import { layoutStore, clearPinnedTrack } from '@/stores/layout'
import { ParticipantMetadata } from './ParticipantMetadata' import { ParticipantMetadata } from './ParticipantMetadata'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
export function TrackRefContextIfNeeded( export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
@@ -90,7 +91,13 @@ export const ParticipantTile: (
const isScreenShare = trackReference.source != Track.Source.Camera const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false) const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant) const participantColor = getParticipantColor(trackReference.participant)
const { identity, name } = useParticipantInfo({
participant: trackReference.participant,
})
const participantName = name || identity || 'Unknown'
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = { const interactiveProps = {
@@ -141,11 +148,13 @@ export const ParticipantTile: (
)} )}
<div className="lk-participant-placeholder"> <div className="lk-participant-placeholder">
<ParticipantPlaceholder <ParticipantPlaceholder
participant={trackReference.participant} color={participantColor}
displayedNamed={participantName}
/> />
</div> </div>
{!disableMetadata && ( {!disableMetadata && (
<ParticipantMetadata <ParticipantMetadata
displayedName={participantName}
isScreenShare={isScreenShare} isScreenShare={isScreenShare}
participant={trackReference.participant} participant={trackReference.participant}
/> />
@@ -1,233 +0,0 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import {
RiFullscreenLine,
RiImageCircleAiFill,
RiMicLine,
RiMicOffLine,
RiPushpin2Line,
RiUnpinLine,
} from '@remixicon/react'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import {
isEqualTrackRef,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { useEffect, useRef, useState } from 'react'
import { type Participant, Track } from 'livekit-client'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { useSnapshot } from 'valtio'
import { layoutStore, setPinnedTrack, clearPinnedTrack } from '@/stores/layout'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
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>
)
}
const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
const EffectsButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size={'sm'}
variant={'primaryTextDark'}
square
tooltip={t('effects')}
onPress={() => !isEffectsOpen && toggleEffects()}
>
<RiImageCircleAiFill />
</Button>
)
}
const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (isVisible) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
})
} else {
setOpacity(0)
}
}, [isVisible])
const handleMouseMove = () => {
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
}
idleTimerRef.current = window.setTimeout(() => {
setIsIdleRef(true)
}, MOUSE_IDLE_TIME)
setIsIdleRef(false)
}
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = trackRef.participant.isLocal
const canMute = useCanMute(participant)
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{isVisible && (
<div
className={css({
backgroundColor: 'primaryDark.50',
transition: 'opacity 200ms linear',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
_hover: {
opacity: '0.95 !important',
},
})}
style={{ opacity }}
>
<HStack
gap={0.5}
className={css({
padding: '0.5rem',
_hover: {
opacity: '1 !important',
},
})}
>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{participant.isLocal ? (
<EffectsButton />
) : (
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
)}
</div>
)
}
@@ -0,0 +1,23 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Button } from '@/primitives'
import { RiImageCircleAiFill } from '@remixicon/react'
export const EffectsButton = React.memo(() => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size={'sm'}
variant={'primaryTextDark'}
square
tooltip={t('effects')}
onPress={() => !isEffectsOpen && toggleEffects()}
>
<RiImageCircleAiFill />
</Button>
)
})
EffectsButton.displayName = 'EffectsButton'
@@ -0,0 +1,32 @@
import {
isEqualTrackRef,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { Button } from '@/primitives'
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
export const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
@@ -0,0 +1,45 @@
import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
@@ -0,0 +1,112 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { Track } from 'livekit-client'
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
}
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}, MOUSE_IDLE_TIME)
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
data-visible={isVisible || undefined}
aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
>
{isVisible && children}
</div>
)
}
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = participant.isLocal
const canMute = useCanMute(participant)
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div
className={css({
backgroundColor: 'primaryDark.50',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards',
_hover: {
opacity: 0.95,
},
})}
>
<HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{isLocal ? (
<EffectsButton />
) : (
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
</FadeOverlay>
)
}
@@ -0,0 +1,32 @@
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>
)
}
@@ -0,0 +1,31 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
export const useParticipantRole = () => {
const data = useRoomData()
const updateParticipantRole = async (
identity: string,
role: AssignableParticipantRole
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
try {
return fetchApi(`rooms/${data.id}/update-participant-role/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: identity,
role: role,
}),
})
} catch (error) {
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { updateParticipantRole }
}
@@ -0,0 +1,68 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export const ParticipantName = React.memo(
({ displayedName, isLocal }: { displayedName: string; isLocal: boolean }) => {
const { t } = useTranslation('rooms')
const nameRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => {
const el = nameRef.current
if (!el) return
const truncated = el.scrollWidth > el.clientWidth
setIsTruncated((prev) => (prev === truncated ? prev : truncated))
}, [displayedName])
return (
<Text
as="div"
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
minWidth: 0,
width: 'full',
maxWidth: 'full',
})}
>
<VisualOnlyTooltip
tooltip={displayedName}
disabled={!isTruncated}
className={css({ display: 'flex', minWidth: 0, flex: 1 })}
>
<p
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
minWidth: 0,
})}
ref={nameRef}
>
{displayedName}
</p>
</VisualOnlyTooltip>
{isLocal && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
flexShrink: 0,
})}
>
({t('participants.you')})
</span>
)}
</Text>
)
}
)
ParticipantName.displayName = 'ParticipantName'
@@ -5,7 +5,11 @@ import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin' import {
getParticipantIsRoomAdmin,
getParticipantIsRoomOwner,
getParticipantIsRoomMember,
} from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import { type LocalParticipant, type Participant, Track } from 'livekit-client' import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit' import { isLocal } from '@/utils/livekit'
import { import {
@@ -20,7 +24,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
participant: Participant participant: Participant
@@ -106,52 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
width: 'full', width: 'full',
})} })}
> >
<HStack> <HStack flex="1" minW="0">
<div <div className={css({ position: 'relative', flexShrink: 0 })}>
className={css({
position: 'relative',
})}
>
<Avatar name={name} bgColor={getParticipantColor(participant)} /> <Avatar name={name} bgColor={getParticipantColor(participant)} />
<PinBadge participant={participant} /> <PinBadge participant={participant} />
<UnauthenticatedBadge participant={participant} />
</div> </div>
<VStack gap={0} alignItems="start"> <VStack gap={0} alignItems="start" minW="0" flex="1">
<Text <ParticipantName
variant="sm" displayedName={name}
className={css({ isLocal={isLocal(participant)}
userSelect: 'none', />
cursor: 'default', <Text variant="xsNote">
display: 'flex', {getParticipantIsRoomOwner(participant) && t('participants.host')}
})} {getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
> {getParticipantIsRoomMember(participant) &&
<span t('participants.member')}
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text> </Text>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
</VStack> </VStack>
</HStack> </HStack>
<HStack> <HStack flexShrink={0}>
<MicIndicator participant={participant} /> <MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} /> <ParticipantMenuButton participant={participant} />
</HStack> </HStack>
@@ -1,7 +1,6 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useLowerHandParticipant } from '../api/lowerHandParticipant' import { useLowerHandParticipant } from '../api/lowerHandParticipant'
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly' import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
import { ParticipantName } from './ParticipantName'
const ActionButton = ({ const ActionButton = ({
participant, participant,
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
} }
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => { export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<HStack <HStack
role="listitem" role="listitem"
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
width: 'full', width: 'full',
})} })}
> >
<HStack> <HStack flex="1" minW="0" overflow="hidden">
<Avatar name={name} bgColor={getParticipantColor(participant)} /> <Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text <ParticipantName displayedName={name} isLocal={isLocal(participant)} />
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack> </HStack>
<HStack flexShrink={0}>
<AdminOrOwnerOnly> <AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} /> <ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly> </AdminOrOwnerOnly>
</HStack> </HStack>
</HStack>
) )
} }
@@ -0,0 +1,43 @@
import { Participant } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useParticipantAttribute } from '@livekit/components-react'
import { RiErrorWarningFill } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useTranslation } from 'react-i18next'
export const UnauthenticatedBadge = ({
participant,
}: {
participant: Participant
}) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participants.unauthenticated',
})
const is_authenticated = useParticipantAttribute('is_authenticated', {
participant,
})
if (is_authenticated == 'true') return
return (
<div
className={css({
height: '18px',
width: '18px',
borderRadius: '100%',
background: 'orange.200',
color: 'orange.800',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
bottom: '-2px',
right: '-4px',
})}
>
<VisualOnlyTooltip tooltip={t('badge')}>
<RiErrorWarningFill size={14} aria-hidden />
</VisualOnlyTooltip>
</div>
)
}
@@ -1,10 +1,11 @@
import { Button, Text } from '@/primitives' import { Button } from '@/primitives'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '../api/listWaitingParticipants' import { WaitingParticipant } from '../api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { ParticipantName } from './ParticipantName'
export const WaitingParticipantRow = ({ export const WaitingParticipantRow = ({
participant, participant,
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
width: 'full', width: 'full',
})} })}
> >
<HStack <HStack flex="1" minW="0">
className={css({
flex: '1',
minWidth: '0',
})}
>
<Avatar name={participant.username} bgColor={participant.color} /> <Avatar name={participant.username} bgColor={participant.color} />
<Text <ParticipantName displayedName={participant.username} isLocal={false} />
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flex: '1',
minWidth: '0',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
</HStack> </HStack>
<HStack <HStack gap="0.25rem" flexShrink={0}>
gap="0.25rem"
className={css({
flexShrink: '0',
})}
>
<Button <Button
size="sm" size="sm"
variant="tertiary" variant="tertiary"
@@ -1,24 +1,46 @@
import { Menu as RACMenu } from 'react-aria-components' import { Menu as RACMenu } from 'react-aria-components'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { PinMenuItem } from './items/PinMenuItem' import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem' import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PromoteMenuItem } from './items/PromoteMenuItem'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useParticipantAttributes } from '@livekit/components-react'
export const ParticipantMenu = ({ export const ParticipantMenu = ({
participant, participant,
}: { }: {
participant: Participant participant: Participant
}) => { }) => {
const isAdminOrOwner = useIsAdminOrOwner() const isLocalUserAdminOrOwner = useIsAdminOrOwner()
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
const { attributes } = useParticipantAttributes({ participant })
const isAdmin = attributes?.room_role === 'administrator'
const isOwner = attributes?.room_role === 'owner'
const isAuthenticated = attributes?.is_authenticated == 'true'
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
return ( return (
<RACMenu <RACMenu
style={{ style={{
minWidth: '75px', minWidth: '100px',
}} }}
> >
<PinMenuItem participant={participant} /> <PinMenuItem participant={participant} />
{canModerateParticipant && <RemoveMenuItem participant={participant} />} {canManage && (
<RemoveMenuItem
identity={participant.identity}
displayedName={participant.name}
/>
)}
{canManage && isAuthenticated && (
<PromoteMenuItem
identity={participant.identity}
isAdmin={isAdmin}
displayedName={participant.name}
/>
)}
</RACMenu> </RACMenu>
) )
} }
@@ -1,7 +1,6 @@
import { Button, Menu } from '@/primitives' import { Button, Menu } from '@/primitives'
import { RiMore2Fill } from '@remixicon/react' import { RiMore2Fill } from '@remixicon/react'
import { ParticipantMenu } from './ParticipantMenu' import { ParticipantMenu } from './ParticipantMenu'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
participant: Participant participant: Participant
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner) return null
return ( return (
<Menu> <Menu>
<Button <Button
@@ -0,0 +1,47 @@
import React, { useCallback } from 'react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { RiAdminLine, RiUserMinusLine } from '@remixicon/react'
import { useParticipantRole } from '@/features/participants/api/updateParticipantRole'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
type PromoteMenuItemProps = {
identity: string
displayedName?: string
isAdmin: boolean
}
export const PromoteMenuItem = React.memo(
({ identity, displayedName, isAdmin }: PromoteMenuItemProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { updateParticipantRole } = useParticipantRole()
const label = isAdmin ? 'demote' : 'promote'
const Icon = isAdmin ? RiUserMinusLine : RiAdminLine
const toggleRole = useCallback(
() =>
updateParticipantRole(identity, isAdmin ? 'member' : 'administrator'),
[isAdmin, updateParticipantRole, identity]
)
return (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: displayedName || identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleRole}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
PromoteMenuItem.displayName = 'PromoteMenuItem'
@@ -1,4 +1,4 @@
import type { Participant } from 'livekit-client' import React from 'react'
import { menuRecipe } from '@/primitives/menuRecipe' import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
@@ -6,18 +6,22 @@ import { MenuItem } from 'react-aria-components'
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant' import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export const RemoveMenuItem = ({ type RemoveMenuItemProps = {
participant, identity: string
}: { displayedName?: string
participant: Participant }
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu.remove' }) export const RemoveMenuItem = React.memo(
({ identity, displayedName }: RemoveMenuItemProps) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participantMenu.remove',
})
const { removeParticipant } = useRemoveParticipant() const { removeParticipant } = useRemoveParticipant()
return ( return (
<MenuItem <MenuItem
aria-label={t('ariaLabel', { name: participant.name })} aria-label={t('ariaLabel', { name: displayedName || identity })}
className={menuRecipe({ icon: true }).item} className={menuRecipe({ icon: true }).item}
onAction={() => removeParticipant(participant)} onAction={() => removeParticipant(identity)}
> >
<HStack gap={0.25}> <HStack gap={0.25}>
<RiCloseLine size={20} aria-hidden /> <RiCloseLine size={20} aria-hidden />
@@ -26,3 +30,6 @@ export const RemoveMenuItem = ({
</MenuItem> </MenuItem>
) )
} }
)
RemoveMenuItem.displayName = 'RemoveMenuItem'
@@ -28,3 +28,6 @@ export type ApiRoom = {
livekit?: ApiLiveKit livekit?: ApiLiveKit
configuration?: RoomConfiguration configuration?: RoomConfiguration
} }
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
@@ -1,11 +1,10 @@
import type { Participant } from 'livekit-client'
import { useRoomData } from '../livekit/hooks/useRoomData' import { useRoomData } from '../livekit/hooks/useRoomData'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
export const useRemoveParticipant = () => { export const useRemoveParticipant = () => {
const data = useRoomData() const data = useRoomData()
const removeParticipant = async (participant: Participant) => { const removeParticipant = async (identity: string) => {
if (!data?.id) { if (!data?.id) {
throw new Error('Room id is not available') throw new Error('Room id is not available')
} }
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
return fetchApi(`rooms/${data.id}/remove-participant/`, { return fetchApi(`rooms/${data.id}/remove-participant/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
participant_identity: participant.identity, participant_identity: identity,
}), }),
}) })
} }
@@ -316,7 +316,7 @@ export const Join = ({
refetch: refetchRoom, refetch: refetchRoom,
} = useQuery({ } = useQuery({
queryKey: [keys.room, roomId], queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId, username }), queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
retry: false, retry: false,
enabled: false, enabled: false,
@@ -339,7 +339,7 @@ export const Join = ({
const { status, startWaiting } = useLobby({ const { status, startWaiting } = useLobby({
roomId, roomId,
username, username: username || user?.full_name || 'anonymous',
onAccepted: handleAccepted, onAccepted: handleAccepted,
}) })
@@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter' import { useParams } from 'wouter'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager' import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager' import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { useEffect } from 'react'
import { closeSidePanel } from '@/stores/layout'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
export const Admin = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -23,6 +26,14 @@ export const Admin = () => {
const { mutateAsync: patchRoom } = usePatchRoom() const { mutateAsync: patchRoom } = usePatchRoom()
const isAdminOrOwner = useIsAdminOrOwner()
useEffect(() => {
if (!isAdminOrOwner) {
closeSidePanel()
}
}, [isAdminOrOwner])
const { data: readOnlyData } = useQuery({ const { data: readOnlyData } = useQuery({
queryKey: [keys.room, roomId], queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId }), queryFn: () => fetchRoom({ roomId }),
@@ -0,0 +1,6 @@
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
export const RoomMetadataSynchronizer = () => {
useSyncLiveKitMetadata()
return null
}
@@ -1,4 +1,4 @@
import { layoutStore } from '@/stores/layout' import { closeSidePanel, layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text' import { text } from '@/primitives/Text'
@@ -199,10 +199,7 @@ export const SidePanel = () => {
ref={asideRef} ref={asideRef}
title={title} title={title}
ariaLabel={t('ariaLabel', { title })} ariaLabel={t('ariaLabel', { title })}
onClose={() => { onClose={closeSidePanel}
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`), content: t(`content.${activeSubPanelId || activePanelId}`),
})} })}
@@ -1,6 +1,17 @@
import { useRoomData } from './useRoomData' import {
useParticipantAttribute,
useRoomContext,
} from '@livekit/components-react'
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
export const useIsAdminOrOwner = () => { export const useIsAdminOrOwner = () => {
const apiRoomData = useRoomData() const room = useRoomContext()
return apiRoomData?.is_administrable const localParticipant = room.localParticipant
const role = useParticipantAttribute('room_role', {
participant: localParticipant,
})
return (
role !== undefined &&
['administrator', 'owner'].includes(role as ParticipantRole)
)
} }
@@ -8,7 +8,7 @@ import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions' import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin' import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import Source = Track.Source import Source = Track.Source
import { import {
NotificationType, NotificationType,
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
}) })
const unprivilegedRemoteParticipants = remoteParticipants.filter( const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdmin(participant) (participant) => !getParticipantIsRoomAdminOrOwner(participant)
) )
const currentSources = useMemo(() => { const currentSources = useMemo(() => {
@@ -13,8 +13,7 @@ import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from './useRoomData' import { useRoomData } from './useRoomData'
/** /**
* Shape of the LiveKit room metadata blob pushed by the backend. * The subset of LiveKit's room metadata this hook actually uses.
* Matches RoomManagement.update_metadata {"configuration": room.configuration}
*/ */
type RoomLiveKitMetadata = { type RoomLiveKitMetadata = {
configuration?: RoomConfiguration configuration?: RoomConfiguration
@@ -11,9 +11,9 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording' import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver' import { ConnectionObserver } from '../components/ConnectionObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useRoomPageTitle } from '../hooks/useRoomPageTitle' import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription' import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
@@ -53,7 +53,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
*/ */
export function VideoConference({ ...props }: VideoConferenceProps) { export function VideoConference({ ...props }: VideoConferenceProps) {
useRoomPageTitle() useRoomPageTitle()
useSyncLiveKitMetadata()
useNoiseReduction() useNoiseReduction()
const { isOpen: isPictureInPictureOpen } = usePictureInPicture() const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
@@ -62,6 +61,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
return ( return (
<> <>
<RoomMetadataSynchronizer />
<ConnectionObserver /> <ConnectionObserver />
<ChatProvider /> <ChatProvider />
<VideoResolutionSubscription /> <VideoResolutionSubscription />
@@ -0,0 +1,8 @@
import type { Participant } from 'livekit-client'
export const getParticipantIsAuthenticated = (
participant: Participant
): boolean => {
const isAuthenticated = participant.attributes?.is_authenticated
return isAuthenticated == 'true'
}
@@ -1,12 +0,0 @@
import type { Participant } from 'livekit-client'
export const getParticipantIsRoomAdmin = (
participant: Participant
): boolean => {
const attributes = participant.attributes
if (!attributes) {
return false
}
return attributes?.room_admin === 'true'
}
@@ -0,0 +1,23 @@
import type { Participant } from 'livekit-client'
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
const participantHasRoomRole = (
participant: Participant,
roles: ParticipantRole[]
): boolean => {
const role = participant.attributes?.room_role
return role !== undefined && roles.includes(role as ParticipantRole)
}
export const getParticipantIsRoomAdmin = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['administrator'])
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['owner'])
export const getParticipantIsRoomMember = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['member'])
export const getParticipantIsRoomAdminOrOwner = (
participant: Participant
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
@@ -54,5 +54,13 @@
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist." "default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
} }
}, },
"roleChanged": {
"body": "Deine Rolle wurde geändert. Du bist jetzt {{role}}.",
"roles": {
"owner": "Host",
"administrator": "Co-host",
"member": "Mitglied"
}
},
"openMenu": "Menü öffnen" "openMenu": "Menü öffnen"
} }
+13
View File
@@ -583,11 +583,16 @@
"you": "Du", "you": "Du",
"unknown": "Unbekannte Person", "unknown": "Unbekannte Person",
"host": "Host", "host": "Host",
"cohost": "Co-host",
"member": "Mitglied",
"contributors": "Teilnehmende", "contributors": "Teilnehmende",
"collapsable": { "collapsable": {
"open": "{{name}}-Liste öffnen", "open": "{{name}}-Liste öffnen",
"close": "{{name}}-Liste schließen" "close": "{{name}}-Liste schließen"
}, },
"unauthenticated": {
"badge": "Nicht authentifizierter Benutzer"
},
"muteYourself": "Mikrofon ausschalten", "muteYourself": "Mikrofon ausschalten",
"muteParticipant": "{{name}}s Mikrofon ausschalten", "muteParticipant": "{{name}}s Mikrofon ausschalten",
"muteParticipantAlert": { "muteParticipantAlert": {
@@ -627,6 +632,14 @@
"pin": { "pin": {
"label": "Anheften", "label": "Anheften",
"ariaLabel": "Hefte {{name}} an" "ariaLabel": "Hefte {{name}} an"
},
"promote": {
"label": "Zum Co-Host machen",
"ariaLabel": "{{name}} zum Co-Host machen"
},
"demote": {
"label": "Co-Host-Rolle entfernen",
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
} }
}, },
"pinAnnouncements": { "pinAnnouncements": {
@@ -19,11 +19,12 @@
"paragraphs": [ "paragraphs": [
"Visio ist das Produkt, dessen Nutzung in diesen Bedingungen beschrieben wird und das auf der folgenden Seite vorgestellt ist: https://visio.numerique.gouv.fr.", "Visio ist das Produkt, dessen Nutzung in diesen Bedingungen beschrieben wird und das auf der folgenden Seite vorgestellt ist: https://visio.numerique.gouv.fr.",
"Nutzerinnen und Nutzer von Visio sind alle Personen, die Visio zu Zwecken der Videokommunikation verwenden, unabhängig davon, ob sie Mitarbeitende, Partner oder Dienstleister der zuständigen Verwaltungen sind. Die zuständige Verwaltung ist die staatliche Verwaltung oder ihr unterstellte Einrichtung, die Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt.", "Nutzerinnen und Nutzer von Visio sind alle Personen, die Visio zu Zwecken der Videokommunikation verwenden, unabhängig davon, ob sie Mitarbeitende, Partner oder Dienstleister der zuständigen Verwaltungen sind. Die zuständige Verwaltung ist die staatliche Verwaltung oder ihr unterstellte Einrichtung, die Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt.",
"Raumorganisatorinnen und -organisatoren sind Nutzerinnen und Nutzer, die nach Anmeldung über ProConnect für einen Videokonferenzraum verantwortlich sind." "Raumorganisatorinnen und -organisatoren sind Nutzerinnen und Nutzer, die nach Anmeldung über ProConnect für einen Videokonferenzraum verantwortlich sind.",
"Die zuständige Verwaltung ist die Verwaltung, die dem staatlichen Informations- und Kommunikationssystem unterliegt und Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt."
] ]
}, },
"article4": { "article4": {
"title": "4. Zugang zur Anwendung", "title": "4. Zugang zum Dienst",
"definition": "Visio wird den Nutzerinnen und Nutzern von ihrer zuständigen Verwaltung zur Verfügung gestellt. Organisatorinnen und Organisatoren können anschließend unter eigener Verantwortung einen Zugangslink zum Raum an beliebige Personen weitergeben." "definition": "Visio wird den Nutzerinnen und Nutzern von ihrer zuständigen Verwaltung zur Verfügung gestellt. Organisatorinnen und Organisatoren können anschließend unter eigener Verantwortung einen Zugangslink zum Raum an beliebige Personen weitergeben."
}, },
"article5": { "article5": {
@@ -32,7 +33,7 @@
"section1": { "section1": {
"title": "5.1. Funktionen für Organisatorinnen und Organisatoren", "title": "5.1. Funktionen für Organisatorinnen und Organisatoren",
"content": "Visio ermöglicht Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.", "content": "Visio ermöglicht Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
"paragraph1": "Jede Mitarbeiterin und jeder Mitarbeiter des Staates oder einer ihm unterstellten Einrichtung, deren Tätigkeiten mit der Nutzung von Visio vereinbar sind oder die keinen besonderen Vertraulichkeits- oder Berufsgeheimnispflichten unterliegen, kann eine Videokonferenz erstellen und organisieren. Der Einladungslink kann anschließend mit anderen Personen, Nutzerinnen und Nutzern, innerhalb oder außerhalb der Verwaltung geteilt werden.", "paragraph1": "Sie oder er kann anschließend den Besprechungslink mit anderen Personen, Nutzerinnen und Nutzern, teilen, unabhängig davon, ob diese zur Verwaltung gehören oder nicht.",
"paragraph2": "Zu diesem Zweck kann jede Raumorganisatorin bzw. jeder Raumorganisator:", "paragraph2": "Zu diesem Zweck kann jede Raumorganisatorin bzw. jeder Raumorganisator:",
"capabilities": [ "capabilities": [
"den Zugang zum Raum durch einen Warteraum schützen;", "den Zugang zum Raum durch einen Warteraum schützen;",
@@ -41,7 +42,7 @@
"eine Nutzerin oder einen Nutzer aus dem Raum entfernen;", "eine Nutzerin oder einen Nutzer aus dem Raum entfernen;",
"das Transkriptionstool der Sitzung nach Einholung der Zustimmung der Nutzerinnen und Nutzer unter eigener Verantwortung verwenden." "das Transkriptionstool der Sitzung nach Einholung der Zustimmung der Nutzerinnen und Nutzer unter eigener Verantwortung verwenden."
], ],
"paragraph3": "Nur Mitarbeitende, deren ProConnect-Konto zu einer zur Raumorganisation in Visio berechtigten Domäne gehört oder die keiner besonderen Vertraulichkeits- oder Berufsgeheimnispflicht unterliegen, dürfen die Anwendung und ihre Funktionen nutzen oder kontrollieren.", "paragraph3": "Mitarbeitende sollten sich an ihre zuständige Verwaltung wenden, um den Rahmen der Nutzung von Visio sicherzustellen, insbesondere bei Besprechungen mit erhöhten Vertraulichkeitsanforderungen.",
"paragraph4": "Das automatische Transkriptionstool kann von Organisatorinnen und Organisatoren über das Einstellungsmenü des Raums genutzt werden. Es liegt in ihrer Verantwortung, die Zustimmung der an der transkribierten Sitzung teilnehmenden Nutzerinnen und Nutzer sicherzustellen. Die Transkriptionssprache kann im selben Menü geändert werden. Am Ende der Sitzung erhält die Organisatorin bzw. der Organisator einen Link zum Zugriff auf die im von der DINUM betriebenen Tool „Docs“ gespeicherte Transkription." "paragraph4": "Das automatische Transkriptionstool kann von Organisatorinnen und Organisatoren über das Einstellungsmenü des Raums genutzt werden. Es liegt in ihrer Verantwortung, die Zustimmung der an der transkribierten Sitzung teilnehmenden Nutzerinnen und Nutzer sicherzustellen. Die Transkriptionssprache kann im selben Menü geändert werden. Am Ende der Sitzung erhält die Organisatorin bzw. der Organisator einen Link zum Zugriff auf die im von der DINUM betriebenen Tool „Docs“ gespeicherte Transkription."
}, },
"section2": { "section2": {
@@ -54,5 +54,13 @@
"default": "Your recording is being saved! Well send a notification to your email as soon as its ready." "default": "Your recording is being saved! Well send a notification to your email as soon as its ready."
} }
}, },
"roleChanged": {
"body": "Your role has changed. You are now {{role}}.",
"roles": {
"owner": "Host",
"administrator": "Co-host",
"member": "Member"
}
},
"openMenu": "Open menu" "openMenu": "Open menu"
} }
+13
View File
@@ -582,11 +582,16 @@
"you": "You", "you": "You",
"unknown": "Unknown participant", "unknown": "Unknown participant",
"host": "Host", "host": "Host",
"cohost": "Co-host",
"member": "Member",
"contributors": "Contributors", "contributors": "Contributors",
"collapsable": { "collapsable": {
"open": "Open {{name}} list", "open": "Open {{name}} list",
"close": "Close {{name}} list" "close": "Close {{name}} list"
}, },
"unauthenticated": {
"badge": "User is not authenticated"
},
"muteYourself": "Close your mic", "muteYourself": "Close your mic",
"muteParticipant": "Close the mic of {{name}}", "muteParticipant": "Close the mic of {{name}}",
"muteParticipantAlert": { "muteParticipantAlert": {
@@ -626,6 +631,14 @@
"pin": { "pin": {
"label": "Pin", "label": "Pin",
"ariaLabel": "Pin {{name}}" "ariaLabel": "Pin {{name}}"
},
"promote": {
"label": "Make co-host",
"ariaLabel": "Make {{name}} a co-host"
},
"demote": {
"label": "Remove co-host role",
"ariaLabel": "Remove {{name}}'s co-host role"
} }
}, },
"pinAnnouncements": { "pinAnnouncements": {
@@ -19,11 +19,12 @@
"paragraphs": [ "paragraphs": [
"Visio is the product whose use is described in these terms and presented on the following page: https://visio.numerique.gouv.fr.", "Visio is the product whose use is described in these terms and presented on the following page: https://visio.numerique.gouv.fr.",
"Users of Visio are any persons using Visio for videoconferencing communication purposes, whether they are staff members, partners, or service providers of the relevant administrations. The supervising administration is the State administration or body under its supervision that makes Visio available to its staff members, partners, or service providers.", "Users of Visio are any persons using Visio for videoconferencing communication purposes, whether they are staff members, partners, or service providers of the relevant administrations. The supervising administration is the State administration or body under its supervision that makes Visio available to its staff members, partners, or service providers.",
"Room organizers are users who are responsible for a videoconference room after logging in via ProConnect." "Room organizers are users who are responsible for a videoconference room after logging in via ProConnect.",
"The supervising administration is the administration that falls under the State information and communication system and makes Visio available to its staff members, partners, or service providers."
] ]
}, },
"article4": { "article4": {
"title": "4. Access to the application", "title": "4. Access to the service",
"definition": "Visio is made available to users by their supervising administration. Organizers may then share a link granting access to the room with any person, under their responsibility." "definition": "Visio is made available to users by their supervising administration. Organizers may then share a link granting access to the room with any person, under their responsibility."
}, },
"article5": { "article5": {
@@ -32,7 +33,7 @@
"section1": { "section1": {
"title": "5.1. Features available to organizers", "title": "5.1. Features available to organizers",
"content": "Visio allows staff of State administrations and bodies under their supervision to create a videoconference room from a standard workstation, linked to their ProConnect account.", "content": "Visio allows staff of State administrations and bodies under their supervision to create a videoconference room from a standard workstation, linked to their ProConnect account.",
"paragraph1": "Any State staff member or staff member of a body under State supervision, whose activities are compatible with the use of Visio or who is not subject to specific confidentiality or professional secrecy obligations, may create and organize a videoconference. They may then share the meeting link with other persons, users, whether or not they belong to the administration.", "paragraph1": "They may then share the meeting link with other persons, users, whether or not they belong to the administration.",
"paragraph2": "For this purpose, each room organizer may:", "paragraph2": "For this purpose, each room organizer may:",
"capabilities": [ "capabilities": [
"Protect access to the room with a waiting room;", "Protect access to the room with a waiting room;",
@@ -41,7 +42,7 @@
"Remove a user from the room;", "Remove a user from the room;",
"Use the meeting transcription tool after obtaining the consent of users, under their responsibility." "Use the meeting transcription tool after obtaining the consent of users, under their responsibility."
], ],
"paragraph3": "Only staff members whose ProConnect account belongs to a domain authorized to organize rooms on Visio or who are not subject to specific confidentiality or professional secrecy obligations may use or have control over the application and its features.", "paragraph3": "Staff members must contact their supervising administration to ensure the proper framework for using Visio, particularly for meetings subject to heightened confidentiality requirements.",
"paragraph4": "The automatic transcription tool can be used by organizers from the room settings menu. Organizers are responsible for ensuring the consent of users participating in the transcribed meeting. The transcription language can be changed from the same menu. At the end of the meeting, a link is sent to the organizer to access the transcription stored in the tool called Docs operated by DINUM." "paragraph4": "The automatic transcription tool can be used by organizers from the room settings menu. Organizers are responsible for ensuring the consent of users participating in the transcribed meeting. The transcription language can be changed from the same menu. At the end of the meeting, a link is sent to the organizer to access the transcription stored in the tool called Docs operated by DINUM."
}, },
"section2": { "section2": {
@@ -54,5 +54,13 @@
"default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès quil sera prêt." "default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès quil sera prêt."
} }
}, },
"roleChanged": {
"body": "Votre rôle a changé, vous êtes maitenant {{role}}.",
"roles": {
"owner": "Organisateur",
"administrator": "Co-organisateur",
"member": "Membre"
}
},
"openMenu": "Ouvrir le menu" "openMenu": "Ouvrir le menu"
} }
+13
View File
@@ -583,10 +583,15 @@
"unknown": "Participant inconnu", "unknown": "Participant inconnu",
"contributors": "Contributeurs", "contributors": "Contributeurs",
"host": "Organisateur de la réunion", "host": "Organisateur de la réunion",
"cohost": "Co-organisateur",
"member": "Membre",
"collapsable": { "collapsable": {
"open": "Ouvrir la liste {{name}}", "open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}" "close": "Fermer la liste {{name}}"
}, },
"unauthenticated": {
"badge": "Utilisateur non authentifié"
},
"muteYourself": "Couper votre micro", "muteYourself": "Couper votre micro",
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"muteParticipantAlert": { "muteParticipantAlert": {
@@ -626,6 +631,14 @@
"pin": { "pin": {
"label": "Épingler", "label": "Épingler",
"ariaLabel": "Épingler {{name}}" "ariaLabel": "Épingler {{name}}"
},
"promote": {
"label": "Nommer co-organisateur",
"ariaLabel": "Nommer {{name}} co-organisateur"
},
"demote": {
"label": "Retirer le rôle de co-organisateur",
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
} }
}, },
"pinAnnouncements": { "pinAnnouncements": {
@@ -19,11 +19,12 @@
"paragraphs": [ "paragraphs": [
"Visio est le produit dont lutilisation est décrite dans les présentes modalités et présenté sur la page suivante : https://visio.numerique.gouv.fr.", "Visio est le produit dont lutilisation est décrite dans les présentes modalités et présenté sur la page suivante : https://visio.numerique.gouv.fr.",
"Les utilisateurs et utilisatrices de Visio sont toute personne utilisant Visio à des fins de communication en visioconférence, quelles soient des agents, des partenaires ou des prestataires des administrations de rattachement. Ladministration de rattachement est ladministration de lEtat et des organismes sous sa tutelle qui met à disposition Visio à ses agents, partenaires ou prestataires.", "Les utilisateurs et utilisatrices de Visio sont toute personne utilisant Visio à des fins de communication en visioconférence, quelles soient des agents, des partenaires ou des prestataires des administrations de rattachement. Ladministration de rattachement est ladministration de lEtat et des organismes sous sa tutelle qui met à disposition Visio à ses agents, partenaires ou prestataires.",
"Les organisateurs et organisatrices de salon sont des utilisateurs et utilisatrices qui sont responsables dun salon de visioconférence après s’être connecté par ProConnect." "Les organisateurs et organisatrices de salon sont des utilisateurs et utilisatrices qui sont responsables dun salon de visioconférence après s’être connecté par ProConnect.",
"Ladministration de rattachement est ladministration relevant du système dinformation et de communication de lEtat qui met à disposition Visio à ses agents, partenaires ou prestataires."
] ]
}, },
"article4": { "article4": {
"title": "4. Accès à lapplication", "title": "4. Accès au service",
"definition": "Visio est mis à disposition des utilisateurs et utilisatrices par leur administration de rattachement. Les organisateurs et organisatrices peuvent ensuite transmettre un lien permettant daccéder au salon à toute personne, sous leur responsabilité." "definition": "Visio est mis à disposition des utilisateurs et utilisatrices par leur administration de rattachement. Les organisateurs et organisatrices peuvent ensuite transmettre un lien permettant daccéder au salon à toute personne, sous leur responsabilité."
}, },
"article5": { "article5": {
@@ -32,7 +33,7 @@
"section1": { "section1": {
"title": "5.1. Fonctionnalités ouvertes aux organisateurs et organisatrices", "title": "5.1. Fonctionnalités ouvertes aux organisateurs et organisatrices",
"content": "Visio offre aux agents des administrations de lEtat et des organismes sous sa tutelle la possibilité de créer un salon de visioconférence depuis un poste de travail standard, lié à leur compte ProConnect.", "content": "Visio offre aux agents des administrations de lEtat et des organismes sous sa tutelle la possibilité de créer un salon de visioconférence depuis un poste de travail standard, lié à leur compte ProConnect.",
"paragraph1": "Tout agent de l’État ou dun organisme sous sa tutelle, dont les activités sont compatibles avec lutilisation de Visio ou qui nest pas soumis à des obligations particulières de confidentialité ou de secret professionnel, peut créer et organiser une visioconférence. Il ou elle peut ensuite partager le lien de la réunion avec dautres personnes, utilisateurs et utilisatrices, quelles fassent partie de ladministration ou non.", "paragraph1": "Il ou elle peut ensuite partager le lien de la réunion avec dautres personnes, utilisateurs et utilisatrices, quelles fassent partie de ladministration ou non.",
"paragraph2": "A cet effet, chaque organisateur ou organisatrice de salon peut :", "paragraph2": "A cet effet, chaque organisateur ou organisatrice de salon peut :",
"capabilities": [ "capabilities": [
"Protéger laccès au salon par une salle dattente ;", "Protéger laccès au salon par une salle dattente ;",
@@ -41,7 +42,7 @@
"Exclure un utilisateur ou utilisatrice du salon ;", "Exclure un utilisateur ou utilisatrice du salon ;",
"Utiliser loutil de transcription de la réunion après avoir obtenu laval des utilisateurs et des utilisatrices, sous sa responsabilité." "Utiliser loutil de transcription de la réunion après avoir obtenu laval des utilisateurs et des utilisatrices, sous sa responsabilité."
], ],
"paragraph3": "Seuls les agents, dont le compte ProConnect relève dun domaine autorisé à organiser des salons sur Visio ou qui <u>ne sont pas soumis à une obligation de confidentialité particulière ou de secret professionnel</u>, peuvent utiliser ou avoir la maîtrise de lapplication et de ses fonctionnalités.", "paragraph3": "Les agents doivent se rapprocher de leur administration de rattachement pour s'assurer du cadre d'utilisation en sein de Visio, notamment en ce qui concerne des réunions soumises à des obligations renforcées de confidentialité.",
"paragraph4": "Loutil de transcription automatique peut être utilisé par les organisateurs et organisatrices depuis le menu des paramètres du salon. Il appartient aux organisateurs et organisatrices de sassurer de laval des utilisateurs et utilisatrices participants à la réunion transcrite. La langue de transcription peut être modifiée depuis le même menu. À la fin de la réunion, un lien est transmis à lorganisateur ou lorganisatrice pour accéder à la transcription conservée sur loutil intitulé Docs opéré par la DINUM." "paragraph4": "Loutil de transcription automatique peut être utilisé par les organisateurs et organisatrices depuis le menu des paramètres du salon. Il appartient aux organisateurs et organisatrices de sassurer de laval des utilisateurs et utilisatrices participants à la réunion transcrite. La langue de transcription peut être modifiée depuis le même menu. À la fin de la réunion, un lien est transmis à lorganisateur ou lorganisatrice pour accéder à la transcription conservée sur loutil intitulé Docs opéré par la DINUM."
}, },
"section2": { "section2": {
@@ -54,5 +54,13 @@
"default": "We zijn uw opname aan het voltooien! U ontvangt een e-mail zodra deze klaar is." "default": "We zijn uw opname aan het voltooien! U ontvangt een e-mail zodra deze klaar is."
} }
}, },
"roleChanged": {
"body": "Je rol is gewijzigd. Je bent nu {{role}}.",
"roles": {
"owner": "Host",
"administrator": "Co-host",
"member": "Lid"
}
},
"openMenu": "Menu openen" "openMenu": "Menu openen"
} }
+13
View File
@@ -582,11 +582,16 @@
"you": "U", "you": "U",
"unknown": "Onbekende deelnemer", "unknown": "Onbekende deelnemer",
"host": "Host", "host": "Host",
"cohost": "Co-host",
"member": "Lid",
"contributors": "Deelnemers", "contributors": "Deelnemers",
"collapsable": { "collapsable": {
"open": "Open {{name}} lijst", "open": "Open {{name}} lijst",
"close": "Sluit {{name}} lijst" "close": "Sluit {{name}} lijst"
}, },
"unauthenticated": {
"badge": "Niet authentifizierter Benutzer"
},
"muteYourself": "Uw microfoon dempen", "muteYourself": "Uw microfoon dempen",
"muteParticipant": "Demp de microfoon van {{name}}", "muteParticipant": "Demp de microfoon van {{name}}",
"muteParticipantAlert": { "muteParticipantAlert": {
@@ -626,6 +631,14 @@
"pin": { "pin": {
"label": "Pinnen", "label": "Pinnen",
"ariaLabel": "Maak {{name}} vast" "ariaLabel": "Maak {{name}} vast"
},
"promote": {
"label": "Co-host maken",
"ariaLabel": "{{name}} co-host maken"
},
"demote": {
"label": "Co-hostrol verwijderen",
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
} }
}, },
"pinAnnouncements": { "pinAnnouncements": {
@@ -19,11 +19,12 @@
"paragraphs": [ "paragraphs": [
"Visio is het product waarvan het gebruik in deze voorwaarden wordt beschreven en dat wordt gepresenteerd op de volgende pagina: https://visio.numerique.gouv.fr.", "Visio is het product waarvan het gebruik in deze voorwaarden wordt beschreven en dat wordt gepresenteerd op de volgende pagina: https://visio.numerique.gouv.fr.",
"Gebruikers van Visio zijn alle personen die Visio gebruiken voor videocommunicatiedoeleinden, ongeacht of zij medewerkers, partners of dienstverleners zijn van de betrokken administraties. De bevoegde administratie is de overheidsadministratie of de onder haar toezicht staande instantie die Visio ter beschikking stelt aan haar medewerkers, partners of dienstverleners.", "Gebruikers van Visio zijn alle personen die Visio gebruiken voor videocommunicatiedoeleinden, ongeacht of zij medewerkers, partners of dienstverleners zijn van de betrokken administraties. De bevoegde administratie is de overheidsadministratie of de onder haar toezicht staande instantie die Visio ter beschikking stelt aan haar medewerkers, partners of dienstverleners.",
"Ruimteorganisatoren zijn gebruikers die verantwoordelijk zijn voor een videoconferentieruimte na inloggen via ProConnect." "Ruimteorganisatoren zijn gebruikers die verantwoordelijk zijn voor een videoconferentieruimte na inloggen via ProConnect.",
"De bevoegde administratie is de administratie die onder het informatie- en communicatiesysteem van de Staat valt en Visio ter beschikking stelt aan haar medewerkers, partners of dienstverleners."
] ]
}, },
"article4": { "article4": {
"title": "4. Toegang tot de applicatie", "title": "4. Toegang tot de dienst",
"definition": "Visio wordt aan gebruikers ter beschikking gesteld door hun bevoegde administratie. Organisatoren kunnen vervolgens, onder hun verantwoordelijkheid, een toegangslink tot de ruimte delen met elke persoon." "definition": "Visio wordt aan gebruikers ter beschikking gesteld door hun bevoegde administratie. Organisatoren kunnen vervolgens, onder hun verantwoordelijkheid, een toegangslink tot de ruimte delen met elke persoon."
}, },
"article5": { "article5": {
@@ -32,7 +33,7 @@
"section1": { "section1": {
"title": "5.1. Functionaliteiten voor organisatoren", "title": "5.1. Functionaliteiten voor organisatoren",
"content": "Visio biedt medewerkers van overheidsadministraties en onder hun toezicht staande instanties de mogelijkheid om vanaf een standaardwerkstation een videoconferentieruimte aan te maken, gekoppeld aan hun ProConnect-account.", "content": "Visio biedt medewerkers van overheidsadministraties en onder hun toezicht staande instanties de mogelijkheid om vanaf een standaardwerkstation een videoconferentieruimte aan te maken, gekoppeld aan hun ProConnect-account.",
"paragraph1": "Elke medewerker van de Staat of van een onder zijn toezicht staande instantie, van wie de activiteiten verenigbaar zijn met het gebruik van Visio of die niet onder specifieke vertrouwelijkheids- of beroepsgeheimverplichtingen valt, kan een videoconferentie aanmaken en organiseren. De vergaderlink kan vervolgens worden gedeeld met andere personen, gebruikers, binnen of buiten de administratie.", "paragraph1": "Hij of zij kan vervolgens de vergaderlink delen met andere personen, gebruikers, of zij nu wel of niet deel uitmaken van de administratie.",
"paragraph2": "Daartoe kan elke ruimteorganisator:", "paragraph2": "Daartoe kan elke ruimteorganisator:",
"capabilities": [ "capabilities": [
"de toegang tot de ruimte beveiligen met een wachtruimte;", "de toegang tot de ruimte beveiligen met een wachtruimte;",
@@ -41,7 +42,7 @@
"een gebruiker uit de ruimte verwijderen;", "een gebruiker uit de ruimte verwijderen;",
"de transcriptietool van de vergadering gebruiken na toestemming van de gebruikers, onder eigen verantwoordelijkheid." "de transcriptietool van de vergadering gebruiken na toestemming van de gebruikers, onder eigen verantwoordelijkheid."
], ],
"paragraph3": "Alleen medewerkers van wie het ProConnect-account behoort tot een domein dat gemachtigd is om ruimtes op Visio te organiseren, of die niet onder specifieke vertrouwelijkheids- of beroepsgeheimverplichtingen vallen, mogen de applicatie en haar functionaliteiten gebruiken of beheren.", "paragraph3": "Medewerkers moeten contact opnemen met hun bevoegde administratie om zeker te zijn van het gebruikskader binnen Visio, met name voor vergaderingen waarvoor strengere vertrouwelijkheidsverplichtingen gelden.",
"paragraph4": "De automatische transcriptietool kan door organisatoren worden gebruikt via het instellingenmenu van de ruimte. Het is de verantwoordelijkheid van de organisatoren om de toestemming te verkrijgen van de gebruikers die deelnemen aan de getranscribeerde vergadering. De transcriptietaal kan in hetzelfde menu worden gewijzigd. Aan het einde van de vergadering ontvangt de organisator een link om de transcriptie te raadplegen die wordt bewaard in de door DINUM beheerde tool “Docs”." "paragraph4": "De automatische transcriptietool kan door organisatoren worden gebruikt via het instellingenmenu van de ruimte. Het is de verantwoordelijkheid van de organisatoren om de toestemming te verkrijgen van de gebruikers die deelnemen aan de getranscribeerde vergadering. De transcriptietaal kan in hetzelfde menu worden gewijzigd. Aan het einde van de vergadering ontvangt de organisator een link om de transcriptie te raadplegen die wordt bewaard in de door DINUM beheerde tool “Docs”."
}, },
"section2": { "section2": {
@@ -16,24 +16,19 @@ export type VisualOnlyTooltipProps = {
tooltip: string tooltip: string
ariaLabel?: string ariaLabel?: string
tooltipPosition?: 'top' | 'bottom' tooltipPosition?: 'top' | 'bottom'
disabled?: boolean
className?: string
} }
/** type VisualOnlyTooltipInnerProps = Omit<VisualOnlyTooltipProps, 'disabled'>
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
* const VisualOnlyTooltipInner = ({
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content duplication.
* This CSS wrapper avoids TooltipTrigger no automatic aria-describedby no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children, children,
tooltip, tooltip,
ariaLabel, ariaLabel,
className,
tooltipPosition = 'top', tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => { }: VisualOnlyTooltipInnerProps) => {
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext() const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@@ -135,6 +130,7 @@ export const VisualOnlyTooltip = ({
onMouseLeave={hideTooltip} onMouseLeave={hideTooltip}
onFocus={showTooltip} onFocus={showTooltip}
onBlur={hideTooltip} onBlur={hideTooltip}
className={className}
> >
{wrappedChild} {wrappedChild}
</div> </div>
@@ -183,3 +179,25 @@ export const VisualOnlyTooltip = ({
</> </>
) )
} }
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content duplication.
* This CSS wrapper avoids TooltipTrigger no automatic aria-describedby no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
disabled,
...props
}: VisualOnlyTooltipProps) => {
if (disabled) {
return children
}
return <VisualOnlyTooltipInner {...props}>{children}</VisualOnlyTooltipInner>
}
+12 -2
View File
@@ -22,14 +22,16 @@ type State = {
textAreaValue: string textAreaValue: string
} }
export const chatStore = proxy<State>({ const initialState: State = {
unreadMessages: 0, unreadMessages: 0,
isSending: false, isSending: false,
rows: [], rows: [],
names: {}, names: {},
send: undefined, send: undefined,
textAreaValue: '', textAreaValue: '',
}) }
export const chatStore = proxy<State>({ ...initialState })
const GROUPING_WINDOW_MS = 60_000 const GROUPING_WINDOW_MS = 60_000
@@ -60,3 +62,11 @@ export const persistTextAreaValue = (value: string) => {
export const clearTextAreaValue = () => { export const clearTextAreaValue = () => {
chatStore.textAreaValue = '' chatStore.textAreaValue = ''
} }
export function resetChatStore() {
Object.assign(chatStore, {
...initialState,
rows: [],
names: {},
})
}
+5
View File
@@ -34,3 +34,8 @@ export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
export const clearPinnedTrack = (): void => { export const clearPinnedTrack = (): void => {
layoutStore.pinnedTrackRef = undefined layoutStore.pinnedTrackRef = undefined
} }
export const closeSidePanel = (): void => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}