mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-01 22:52:16 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f06cbd787a |
@@ -10,37 +10,6 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(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
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
- 🐛(backend) preserve recording metadata when updating room access
|
||||
- 🐛(backend) allow any string as sub in the API serializer
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||
- ✨(all) allow forcing SSO display name for authenticated users
|
||||
|
||||
@@ -389,12 +389,6 @@ build-k8s-cluster: \
|
||||
./bin/start-kind.sh
|
||||
.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
|
||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
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')
|
||||
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||
|
||||
@@ -164,7 +164,6 @@ echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - src/agents/uv.lock"
|
||||
echo " - CHANGELOG.md"
|
||||
echo ""
|
||||
print_warning "Next steps:"
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/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
|
||||
@@ -143,24 +143,3 @@ $ make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Monitor Tilt’s 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).
|
||||
|
||||
@@ -344,7 +344,6 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.4",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.24.0"
|
||||
version = "1.23.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.4",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.24.0"
|
||||
version = "1.23.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -16,7 +16,6 @@ class FeatureFlag:
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -6,11 +6,6 @@ from django.http import Http404
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
@@ -171,30 +166,3 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
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
|
||||
|
||||
@@ -183,11 +183,13 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
role=role,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -297,8 +299,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||
"""Base serializer for participant management operations."""
|
||||
|
||||
participant_identity = serializers.CharField(
|
||||
help_text="LiveKit participant identity (matching the user's sub format)"
|
||||
participant_identity = serializers.UUIDField(
|
||||
help_text="LiveKit participant identity (UUID format)"
|
||||
)
|
||||
|
||||
|
||||
@@ -310,15 +312,6 @@ 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"]
|
||||
|
||||
|
||||
@@ -580,25 +573,3 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
# useless bad requests
|
||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||
|
||||
# todo if I can pass the max length directly to the char field
|
||||
code = serializers.CharField(max_length=255, trim_whitespace=True)
|
||||
|
||||
def validate_code(self, value):
|
||||
"""Reject codes whose length cannot match a generated one.
|
||||
|
||||
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
|
||||
url-safe characters. Checking the length against the configured
|
||||
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
|
||||
before any cache lookup.
|
||||
"""
|
||||
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
if len(value) != expected_length:
|
||||
raise serializers.ValidationError("Invalid transit code format.")
|
||||
|
||||
return value
|
||||
|
||||
@@ -73,14 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous transit code exchange attempts.
|
||||
|
||||
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||
best-effort. The security of the exchange rests on the codes'
|
||||
entropy and single use.
|
||||
"""
|
||||
|
||||
scope = "exchange_access_token"
|
||||
|
||||
@@ -69,7 +69,6 @@ from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -89,12 +88,7 @@ from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
@@ -231,76 +225,6 @@ class UserViewSet(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="exchange-access-token",
|
||||
permission_classes=[],
|
||||
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def exchange_access_token(self, request):
|
||||
"""Exchange a single-use transit code for a user access token.
|
||||
|
||||
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||
random string obtained through the external API and delivered to
|
||||
the embedded frontend via a URL fragment, is the credential. Each
|
||||
code can be exchanged exactly once (consuming it deletes it from
|
||||
the cache); replaying a consumed code is denied and logged.
|
||||
|
||||
The issued JWT authenticates the user the code was minted for on
|
||||
the whole core API, exactly like a session cookie would (similar
|
||||
to lib-jitsi-meet's token authentication), and never appears in
|
||||
any URL. Role-based permissions apply unchanged.
|
||||
"""
|
||||
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||
|
||||
if code_data is None:
|
||||
logger.warning("Invalid, expired or already used transit code")
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"Invalid, expired or already used transit code."
|
||||
)
|
||||
|
||||
# Re-check the user at exchange time so that a deactivation after
|
||||
# the transit code was minted is taken into account.
|
||||
try:
|
||||
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
|
||||
except models.User.DoesNotExist as excpt:
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"This account can no longer access the application."
|
||||
) from excpt
|
||||
|
||||
token_service = JwtTokenService(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
# todo - discuss wether it's the relevant scope
|
||||
data = token_service.generate_jwt(
|
||||
user,
|
||||
"user:access",
|
||||
{
|
||||
"token_type": "user_access",
|
||||
"client_id": code_data.get("client_id", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||
user.id,
|
||||
code_data.get("client_id", "unknown"),
|
||||
)
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
@@ -715,53 +639,6 @@ class RoomViewSet(
|
||||
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(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""User access JWT authentication for the Meet core API.
|
||||
|
||||
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||
session cookies are blocked) to authenticate requests on the core API with
|
||||
a JWT, obtained by exchanging a single-use transit code (see
|
||||
core.services.transit_code and the users exchange-access-token endpoint)
|
||||
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||
|
||||
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||
user, not to a resource: once authenticated, the request is treated
|
||||
exactly like a session-authenticated one, and the existing role-based
|
||||
permissions apply unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from core.external_api.authentication import BaseJWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
|
||||
|
||||
|
||||
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for user access tokens.
|
||||
|
||||
Validates user access tokens issued by the users exchange-access-token
|
||||
endpoint and authenticates the user they were issued for. A bearer
|
||||
token that does not verify against the user access token secret is
|
||||
deferred to the next authentication backend; a token that does verify
|
||||
but carries wrong claims is rejected.
|
||||
|
||||
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||
returns None before reading the Authorization header, deferring every
|
||||
request to the next authentication backend.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the backend with user access token settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate the token type and the issuance-audit claim.
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the token verified against the user
|
||||
access token secret but does not carry the expected claims.
|
||||
"""
|
||||
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||
logger.warning("Wrong 'token_type' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
# Every token we issue carries the client_id of the application the
|
||||
# transit code was minted for: its absence means the token does not
|
||||
# come from the exchange endpoint.
|
||||
if not payload.get("client_id"):
|
||||
logger.warning("Missing 'client_id' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
@@ -86,14 +86,6 @@ class HasRequiredRoomScope(BaseScopePermission):
|
||||
}
|
||||
|
||||
|
||||
class HasRequiredUserScope(BaseScopePermission):
|
||||
"""Scope-based permissions for the external user endpoints."""
|
||||
|
||||
scope_map = {
|
||||
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||
}
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -219,62 +218,3 @@ class RoomViewSet(
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
"""Application-delegated API for user operations.
|
||||
|
||||
Provides JWT-authenticated access to user operations for external
|
||||
applications acting on behalf of users. All operations are
|
||||
scope-based. Meant to grow with the other user actions exposed to
|
||||
third parties.
|
||||
|
||||
Supported operations:
|
||||
- transit-code: Mint a single-use transit code for the delegated user
|
||||
(requires 'users:session' scope)
|
||||
"""
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||
]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="transit-code",
|
||||
url_name="transit-code",
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def generate_transit_code(self, request):
|
||||
"""Mint a transit code for the delegated user.
|
||||
|
||||
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||
frontend exchanges it once on
|
||||
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||
equivalent to session-cookie authentication and never exposed in a URL.
|
||||
"""
|
||||
auth_method = type(request.successful_authenticator).__name__
|
||||
client_id = (request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
|
||||
request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"transit_code": code,
|
||||
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='application',
|
||||
name='scopes',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||
),
|
||||
]
|
||||
@@ -769,7 +769,6 @@ class ApplicationScope(models.TextChoices):
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
|
||||
@@ -9,11 +9,6 @@ from livekit import api
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -44,15 +39,10 @@ class RecordingEventsService:
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
utils.update_room_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -68,15 +64,10 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
utils.update_room_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core import models, utils
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -23,11 +23,6 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -182,15 +177,10 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement().update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
utils.update_room_metadata(
|
||||
room_name, {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
|
||||
@@ -104,30 +104,21 @@ class LobbyService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
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.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return (
|
||||
room.is_public
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.RESTRICTED
|
||||
and user.is_authenticated
|
||||
and role is not None
|
||||
)
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
|
||||
def request_entry(
|
||||
@@ -153,9 +144,8 @@ class LobbyService:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
room_id = str(room.id)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
@@ -172,8 +162,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -193,8 +183,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
ListRoomsRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
@@ -30,45 +29,20 @@ class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(
|
||||
self,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
):
|
||||
"""Merge values into a LiveKit room's metadata.
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(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()
|
||||
|
||||
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(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(updated_metadata),
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""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
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Service handling the lifecycle of transit codes.
|
||||
|
||||
A transit code is an opaque, cryptographically random, single-use code
|
||||
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||
user access token on the core API without a session cookie. The code
|
||||
carries no information by itself: everything it references (user, client)
|
||||
is stored server-side in the cache, and consumed atomically on exchange.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class TransitCodeService:
|
||||
"""Create and consume single-use transit codes."""
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(code):
|
||||
"""Build the cache key for a code.
|
||||
|
||||
The code is hashed so that a dump of the cache never reveals
|
||||
directly usable codes.
|
||||
"""
|
||||
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||
|
||||
def create_code(self, user, client_id="unknown"):
|
||||
"""Generate a transit code for a user, and store it.
|
||||
|
||||
The code expires after TRANSIT_CODE_TTL seconds.
|
||||
|
||||
Returns:
|
||||
str: The opaque code to hand to the client.
|
||||
"""
|
||||
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||
# entropy: unguessable and safe to transit through a URL fragment.
|
||||
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
cache.set(
|
||||
self._cache_key(code),
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=settings.TRANSIT_CODE_TTL,
|
||||
)
|
||||
|
||||
return code
|
||||
|
||||
def consume_code(self, code):
|
||||
"""Consume a transit code, enforcing single use.
|
||||
|
||||
The code is deleted from the cache upon consumption. `cache.delete`
|
||||
returns whether a key was actually deleted, so if two requests race
|
||||
on the same code, only one of them wins.
|
||||
|
||||
Returns:
|
||||
dict | None: The data stored at creation time ('user_id',
|
||||
'client_id'), or None if the code is unknown, expired or
|
||||
already consumed.
|
||||
"""
|
||||
if not code:
|
||||
return None
|
||||
|
||||
key = self._cache_key(code)
|
||||
data = cache.get(key)
|
||||
|
||||
if data is None or not cache.delete(key):
|
||||
return None
|
||||
|
||||
return data
|
||||
@@ -34,8 +34,10 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, mediator, mock_worker_service
|
||||
):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
@@ -58,7 +60,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(mock_recording.room.id),
|
||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||
)
|
||||
@@ -67,9 +69,9 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -87,7 +89,7 @@ def test_mediator_start_recording_worker_errors(
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -101,9 +103,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_update_metadata, mediator, mock_worker_service, status
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -117,7 +119,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.core.cache import cache
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -114,38 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||
"""A user access token should create a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
Test rooms API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
@@ -160,40 +156,3 @@ def test_api_rooms_list_pagination_page_size():
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
assert content["previous"] is None
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||
"""A user access token should list rooms exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
RoomFactory() # another user's room, not listed
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
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})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
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
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
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
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user)
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": ["test"]}
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Not a valid string." in str(response.data)
|
||||
assert "Must be a valid UUID." in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_no_update_fields():
|
||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": ["invalid-uuid"]}
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
@@ -3,19 +3,16 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -34,6 +31,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -53,6 +51,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -71,6 +70,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -87,6 +87,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -103,6 +104,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -212,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -258,6 +261,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -274,7 +278,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=None,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -309,6 +313,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -325,7 +330,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -350,6 +355,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -395,6 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -411,7 +418,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=str(RoleChoices.MEMBER),
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -486,6 +493,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -503,43 +511,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=str(user_access.role),
|
||||
is_admin_or_owner=True,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||
"""A user access token should retrieve a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
# Authenticated as the owner: privileged fields are included
|
||||
assert response.data["pin_code"] == room.pin_code
|
||||
|
||||
@@ -3,12 +3,8 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -441,45 +437,3 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_update_authenticated_with_user_access_token():
|
||||
"""Role-based permissions apply unchanged with a user access token."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
# A simple member cannot update the room
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 403
|
||||
|
||||
# An administrator can
|
||||
room.accesses.filter(user=user).update(role="administrator")
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"""
|
||||
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,9 +20,8 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -71,9 +70,9 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
@@ -87,8 +86,8 @@ def test_handle_egress_ended_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -105,9 +104,9 @@ def test_handle_egress_ended_success(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_updated_success(
|
||||
mock_update_metadata, egress_status, status, service
|
||||
mock_update_room_metadata, egress_status, status, service
|
||||
):
|
||||
"""Should successfully update room's metadata."""
|
||||
|
||||
@@ -118,7 +117,7 @@ def test_handle_egress_updated_success(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": status}
|
||||
)
|
||||
|
||||
@@ -130,9 +129,9 @@ def test_handle_egress_updated_success(
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_metadata, egress_status, service
|
||||
mock_update_room_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
@@ -143,7 +142,7 @@ def test_handle_egress_updated_non_handled(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -154,9 +153,9 @@ def test_handle_egress_updated_non_handled(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop and save recording when metadata's update fails."""
|
||||
|
||||
@@ -165,7 +164,7 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -179,9 +178,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_metadata, mock_notify, service
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
@@ -201,15 +200,15 @@ def test_handle_egress_ended_notification_fails(
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_metadata, mock_notify, service
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
@@ -224,16 +223,16 @@ def test_handle_egress_ended_recording_not_found(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_metadata, mock_notify, service
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
@@ -245,8 +244,8 @@ def test_handle_egress_ended_recording_not_active(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -254,9 +253,9 @@ def test_handle_egress_ended_recording_not_active(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_metadata, mock_notify, service
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
@@ -268,16 +267,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_metadata, mock_collector_class, service, settings
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
@@ -307,7 +306,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.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
@@ -336,7 +335,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
@@ -346,7 +345,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
mock_update_metadata,
|
||||
mock_update_room_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
@@ -379,7 +378,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status, expected_status",
|
||||
[
|
||||
@@ -388,7 +387,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
],
|
||||
)
|
||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||
mock_update_metadata,
|
||||
mock_update_room_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
@@ -425,9 +424,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_metadata, egress_status, service, settings
|
||||
mock_update_room_metadata, egress_status, service, settings
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
@@ -446,9 +445,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_metadata, status, service, settings
|
||||
mock_update_room_metadata, status, service, settings
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
|
||||
@@ -9,14 +9,13 @@ import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
@@ -189,17 +188,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
"""Should return True for public rooms regardless of user auth."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
@@ -209,7 +208,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
@@ -219,34 +218,21 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
|
||||
def test_can_bypass_lobby_private_room(lobby_service):
|
||||
"""Should return False for private rooms regardless of user auth if role is not."""
|
||||
"""Should return False for private rooms regardless of user auth."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
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
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
@@ -255,7 +241,7 @@ def test_request_entry_public_room(
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.user = mock.Mock()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
@@ -280,8 +266,8 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -293,7 +279,8 @@ def test_request_entry_trusted_room(
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
@@ -318,8 +305,8 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -332,7 +319,6 @@ def test_request_entry_new_participant(
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -362,7 +348,6 @@ def test_request_entry_waiting_participant(
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -389,7 +374,7 @@ def test_request_entry_accepted_participant(
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.user = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -415,48 +400,8 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
Unit tests for the TransitCodeService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_code_returns_unique_opaque_codes():
|
||||
"""Each created code should be a distinct high-entropy string."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
codes = {service.create_code(user) for _ in range(5)}
|
||||
|
||||
assert len(codes) == 5
|
||||
for code in codes:
|
||||
assert len(code) >= 43
|
||||
|
||||
|
||||
def test_consume_code_returns_stored_data_once():
|
||||
"""Consuming a code should return its data exactly once."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
code = service.create_code(user, client_id="my-app")
|
||||
|
||||
assert service.consume_code(code) == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "my-app",
|
||||
}
|
||||
# Single use: a second consumption fails
|
||||
assert service.consume_code(code) is None
|
||||
|
||||
|
||||
def test_consume_code_unknown_or_empty():
|
||||
"""Unknown or empty codes should not be consumable."""
|
||||
service = TransitCodeService()
|
||||
|
||||
assert service.consume_code("unknown-code") is None
|
||||
assert service.consume_code("") is None
|
||||
assert service.consume_code(None) is None
|
||||
@@ -1,200 +0,0 @@
|
||||
"""
|
||||
Tests for user access JWT authentication on the core API.
|
||||
|
||||
The token authenticates the user on the whole API, exactly like a session
|
||||
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||
with a user access token lives in the room test files.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import RoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_user_access_token(user, **overrides):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
payload.update(overrides)
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_user_access_token_users_me():
|
||||
"""A user access token should authenticate the user on /users/me/."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["email"] == user.email
|
||||
|
||||
|
||||
def test_user_access_token_expired():
|
||||
"""An expired user access token should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = generate_user_access_token(
|
||||
user,
|
||||
iat=now - timedelta(hours=3),
|
||||
exp=now - timedelta(hours=1),
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "token expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_invalid_signature():
|
||||
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
},
|
||||
"wrong-secret-key-padded-for-minimum-len!",
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_wrong_token_type():
|
||||
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, token_type="addons")
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token type" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_missing_client_id_claim():
|
||||
"""A token without the issuance-audit claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, client_id=None)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_inactive_user():
|
||||
"""A user access token for an inactive user should be rejected."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_feature_disabled(settings):
|
||||
"""When the feature is disabled, user access tokens should be ignored."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_does_not_break_session_authentication():
|
||||
"""A session-authenticated user should keep full access to the API."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
|
||||
|
||||
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||
"""An application-delegation JWT must not authenticate on the core API."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"client_id": "some-client",
|
||||
"delegated": True,
|
||||
"scope": "rooms:retrieve",
|
||||
},
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# The user token backend must defer (wrong signature) and the request
|
||||
# must end up unauthenticated.
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Test users API endpoints in the Meet core app: exchange transit code.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_user_access_token(token, settings):
|
||||
"""Decode a user access token with the token secret."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
)
|
||||
|
||||
|
||||
def generate_unknown_code(settings):
|
||||
"""Generate a well-formed code that was never stored."""
|
||||
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Return an anonymous API client with a random source IP.
|
||||
|
||||
A fresh IP per test isolates the anonymous throttle history, both
|
||||
between the tests of this module and between test runs.
|
||||
"""
|
||||
# `secrets` rather than `random`: the global random module is seeded
|
||||
# deterministically by the factories, its sequence repeats across runs.
|
||||
remote_addr = (
|
||||
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||
f".{secrets.randbelow(254) + 1}"
|
||||
)
|
||||
return APIClient(REMOTE_ADDR=remote_addr)
|
||||
|
||||
|
||||
def test_exchange_access_token_missing_code(client):
|
||||
"""The exchange endpoint should validate its input."""
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "code" in response.data
|
||||
|
||||
|
||||
def test_exchange_access_token_malformed_code(client):
|
||||
"""A code whose length cannot match a generated one should be a 400."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": "not-a-valid-code"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid transit code format" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_unknown_code(client, settings):
|
||||
"""A well-formed but unknown code should be denied."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_success(client, settings):
|
||||
"""A valid transit code should be exchangeable for an access token."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user, client_id="my-app")
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
assert response.data["scope"] == "user:access"
|
||||
|
||||
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||
assert payload["token_type"] == "user_access"
|
||||
assert payload["user_id"] == str(user.id)
|
||||
assert payload["client_id"] == "my-app"
|
||||
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
|
||||
|
||||
def test_exchange_access_token_single_use(client):
|
||||
"""A transit code should be exchangeable exactly once."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Replaying the same code must be denied
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_inactive_user(client):
|
||||
"""A code minted for a now-inactive user should be denied."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
user.is_active = False
|
||||
user.save()
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer access" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_feature_disabled(client, settings):
|
||||
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_access_token_throttled(client, settings):
|
||||
"""Anonymous exchange attempts should be rate limited."""
|
||||
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||
initial_rate = throttle_rates["exchange_access_token"]
|
||||
# The rates dict is mutated in place: restore it explicitly, the
|
||||
# `settings` fixture only rolls back attribute assignments.
|
||||
throttle_rates["exchange_access_token"] = "2/minute"
|
||||
|
||||
try:
|
||||
for _ in range(2):
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
finally:
|
||||
throttle_rates["exchange_access_token"] = initial_rate
|
||||
@@ -1,166 +0,0 @@
|
||||
"""
|
||||
Tests for external API /users endpoints (transit codes)
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import ApplicationScope
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_test_token(user, scopes):
|
||||
"""Generate a valid application JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
application = ApplicationFactory()
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now
|
||||
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_transit_code_requires_authentication():
|
||||
"""Minting a transit code without authentication should return 401."""
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_users_transit_code_missing_scope():
|
||||
"""A token without the 'users:session' scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_success(settings):
|
||||
"""A delegated user with the scope should be able to mint a transit code."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||
|
||||
code = response.data["transit_code"]
|
||||
# Opaque, high-entropy random string
|
||||
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
# The code is stored server-side and references the delegated user
|
||||
code_data = TransitCodeService().consume_code(code)
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": mock.ANY,
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_with_rs_token():
|
||||
"""A resource-server-authenticated user should be able to mint a code."""
|
||||
user = UserFactory()
|
||||
|
||||
# todo - add a decorator instead
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||
) as mock_rs_authenticate:
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
|
||||
code_data = TransitCodeService().consume_code(response.data["transit_code"])
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "rs-client",
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_with_rs_token_missing_scope():
|
||||
"""A resource server token without the scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
# todo - add a decorator instead
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
|
||||
):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_feature_disabled(settings):
|
||||
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_transit_code_inactive_user():
|
||||
"""An inactive user should not be able to mint a transit code."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
|
||||
@@ -37,11 +37,6 @@ external_router.register(
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
external_router.register(
|
||||
"users",
|
||||
external_viewsets.UserViewSet,
|
||||
basename="external_user",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
+58
-11
@@ -31,6 +31,7 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -65,7 +66,7 @@ def generate_token(
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
role: Optional[str] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
participant_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
@@ -79,7 +80,7 @@ def generate_token(
|
||||
If none, a value will be generated
|
||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||
role (Optional[str]): Room's access role if any
|
||||
is_admin_or_owner (bool): Whether user has admin privileges
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
@@ -87,7 +88,6 @@ def generate_token(
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -128,11 +128,7 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{
|
||||
"color": color,
|
||||
"room_role": role,
|
||||
"is_authenticated": "true" if user.is_authenticated else "false",
|
||||
}
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -143,7 +139,7 @@ def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
role: Optional[str] = None,
|
||||
is_admin_or_owner: bool,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
@@ -154,7 +150,7 @@ def generate_livekit_config(
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
role (str): Room's access role if any
|
||||
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
||||
color (Optional[str]): Optional color to associate with the participant.
|
||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
@@ -177,7 +173,7 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
role=role,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
participant_id=participant_id,
|
||||
),
|
||||
}
|
||||
@@ -262,6 +258,57 @@ async def notify_participants(room_name: str, notification_data: dict):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -324,7 +324,6 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
@@ -345,11 +344,6 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"exchange_access_token": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
@@ -404,9 +398,6 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
@@ -959,61 +950,6 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# User access tokens (embedded frontend / iframe support)
|
||||
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||
None,
|
||||
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the user access token obtained through the exchange
|
||||
# endpoint. It never transits through a URL, so it can cover a full
|
||||
# meeting (default: 2 hours).
|
||||
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
7200,
|
||||
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the single-use transit code handed to the frontend
|
||||
# through a URL fragment. Kept very short by design: it must only
|
||||
# survive the redirect and the exchange call.
|
||||
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||
60,
|
||||
environ_name="TRANSIT_CODE_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||
"transit-code",
|
||||
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||
48,
|
||||
environ_name="TRANSIT_CODE_NBYTES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
@@ -1311,10 +1247,6 @@ class Test(Base):
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
|
||||
USER_ACCESS_TOKEN_ENABLED = True
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.24.0"
|
||||
version = "1.23.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.24.0"
|
||||
version = "1.23.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+26
-32
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.24.0",
|
||||
"version": "1.23.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.24.0",
|
||||
"version": "1.23.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -15,11 +15,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -29,8 +29,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
@@ -1028,9 +1028,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.46.6",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||
"version": "1.45.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@@ -1049,18 +1049,12 @@
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.35",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -2421,9 +2415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2442,12 +2436,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.1"
|
||||
"@tanstack/query-core": "5.101.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -8125,13 +8119,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.46.6",
|
||||
"@livekit/protocol": "1.45.8",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
@@ -9136,13 +9130,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.395.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||
"version": "1.391.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.38.0",
|
||||
"@posthog/types": "^1.391.1",
|
||||
"@posthog/core": "^1.35.3",
|
||||
"@posthog/types": "^1.390.2",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.24.0",
|
||||
"version": "1.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -22,11 +22,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -36,8 +36,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
|
||||
@@ -114,10 +114,6 @@ const config: Config = {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
overlayIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 0.6 },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
|
||||
@@ -7,20 +7,36 @@ const avatar = cva({
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
subtitles: { width: '40px', height: '40px' },
|
||||
list: { width: '32px', height: '32px' },
|
||||
placeholder: { width: '100%', height: '100%' },
|
||||
subtitles: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
true: { border: '2px solid white' },
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -28,49 +44,37 @@ 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> & {
|
||||
name?: string
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initials = getInitials(name)
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
export const Avatar = ({
|
||||
name,
|
||||
bgColor,
|
||||
context,
|
||||
notification,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
}}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={initials.length > 1 ? 48 : 52}
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Avatar.displayName = 'Avatar'
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text } from '@/primitives'
|
||||
import { ChatMessages } from './ChatMessages'
|
||||
import { ChatTextArea } from './ChatTextArea'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const ChatContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
padding: '0 1.5rem',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const ChatMessagesContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const TextContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'greyscale.50',
|
||||
borderRadius: 4,
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
||||
export const Chat = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
|
||||
return (
|
||||
<ChatContainer>
|
||||
<TextContainer>
|
||||
<Text variant="sm">{t('disclaimer')}</Text>
|
||||
</TextContainer>
|
||||
<ChatMessagesContainer>
|
||||
<ChatMessages />
|
||||
</ChatMessagesContainer>
|
||||
<ChatTextArea />
|
||||
</ChatContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ChatRow } from '@/stores/chat'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ChatMessageMetadata } from './ChatMessageMedata'
|
||||
import { ChatMessageBody } from './ChatMessageBody'
|
||||
|
||||
const StyledContainer = styled('li', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
type ChatMessageProps = {
|
||||
item: ChatRow
|
||||
}
|
||||
|
||||
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
||||
const time = new Date(item.timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
return (
|
||||
<StyledContainer
|
||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||
>
|
||||
{!item.hideMetadata && (
|
||||
<ChatMessageMetadata
|
||||
timestamp={item.timestamp}
|
||||
identity={item.identity}
|
||||
/>
|
||||
)}
|
||||
<ChatMessageBody message={item.message} />
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ChatRow } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
||||
|
||||
export const ChatMessageBody = React.memo(
|
||||
({ message }: ChatMessageBodyProps) => {
|
||||
const formattedMessage = useMemo(() => {
|
||||
return formatChatMessageLinks(message)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
whiteSpace: 'pre-wrap',
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{formattedMessage}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatMessageBody.displayName = 'ChatMessageBody'
|
||||
@@ -1,41 +0,0 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ChatRow, chatStore } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
const StyledContainer = styled('span', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
||||
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
||||
|
||||
export const ChatMessageMetadata = React.memo(
|
||||
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
||||
const time = new Date(timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
|
||||
const { names } = useSnapshot(chatStore)
|
||||
|
||||
const currentDisplayName = useMemo(() => {
|
||||
if (identity) return names[identity] ?? identity
|
||||
}, [names, identity])
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Text bold={true} variant="sm">
|
||||
{currentDisplayName}
|
||||
</Text>
|
||||
<Text variant="smNote" wrap="no">
|
||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
||||
@@ -1,79 +0,0 @@
|
||||
import {
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListLayout,
|
||||
Virtualizer,
|
||||
} from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChatMessage } from './ChatMessage.tsx'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { ChatRow, chatStore } from '@/stores/chat'
|
||||
|
||||
// Estimated height of a chat entry in px. ListLayout measures the real
|
||||
// rendered height of each row; this value is only used to size the
|
||||
// scrollbar before rows have been measured.
|
||||
const ESTIMATED_ROW_HEIGHT = 56
|
||||
const BOTTOM_THRESHOLD_PX = 32
|
||||
|
||||
export const ChatMessages = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
const { rows: items } = useSnapshot(chatStore)
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const stick = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
const pin = () => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
const onScroll = () => {
|
||||
stick.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
||||
}
|
||||
const ro = new ResizeObserver(pin)
|
||||
// Sizer div = ListLayout's content size. Fires on append, and again each
|
||||
// time an estimated row height is replaced by a measured one.
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
||||
ro.observe(el)
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
pin()
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<ListBox
|
||||
ref={listRef}
|
||||
aria-label={t('messagesLabel', 'Chat messages')}
|
||||
items={items}
|
||||
selectionMode="none"
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{(item: ChatRow) => (
|
||||
<ListBoxItem style={{ width: '100%' }}>
|
||||
<ChatMessage item={item} />
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
||||
import { ref } from 'valtio'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||
import type { ChatMessage } from '@livekit/components-core'
|
||||
import {
|
||||
LocalParticipant,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from 'livekit-client'
|
||||
|
||||
export const ChatProvider = () => {
|
||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
const { isChatOpen } = useSidePanel()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
resetChatStore()
|
||||
}, [])
|
||||
|
||||
// Tigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||
// (see https://github.com/livekit/components-js/issues/1158)
|
||||
// Remove this once we refactor chat to use the new text stream approach
|
||||
const latestMessage = chatMessages.slice(-1)[0]
|
||||
if (!latestMessage) return
|
||||
const from = latestMessage.from as
|
||||
| RemoteParticipant
|
||||
| LocalParticipant
|
||||
| undefined
|
||||
|
||||
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
||||
}, [chatMessages, room])
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
||||
appendRow(chatMessages[i])
|
||||
}
|
||||
}, [chatMessages])
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.send = ref(send)
|
||||
}, [send])
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.isSending = isSending
|
||||
}, [isSending])
|
||||
|
||||
// Set the unread messages count
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0) return
|
||||
const last = chatMessages[chatMessages.length - 1]
|
||||
if (isChatOpen) {
|
||||
lastReadMsgAt.current = last.timestamp
|
||||
chatStore.unreadMessages = 0
|
||||
return
|
||||
}
|
||||
chatStore.unreadMessages = chatMessages.filter(
|
||||
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
||||
).length
|
||||
}, [chatMessages, isChatOpen])
|
||||
|
||||
// Listen to participant name changes
|
||||
useEffect(() => {
|
||||
const setName = (p: Participant) => {
|
||||
chatStore.names[p.identity] = p.name || p.identity
|
||||
}
|
||||
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
||||
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||
|
||||
type ChatSubmitButtonProps = {
|
||||
handleSubmit: () => Promise<void>
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export const ChatSubmitButton = React.memo(
|
||||
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
return (
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="sm"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t('button.label')}
|
||||
>
|
||||
<RiSendPlane2Fill />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
||||
@@ -1,92 +0,0 @@
|
||||
import { TextArea } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
chatStore,
|
||||
clearTextAreaValue,
|
||||
persistTextAreaValue,
|
||||
} from '@/stores/chat'
|
||||
import { ChatSubmitButton } from './ChatSubmitButton'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
margin: '0.75rem 0 1.5rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: 4,
|
||||
},
|
||||
})
|
||||
|
||||
export const ChatTextArea = () => {
|
||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.focus({ preventScroll: true })
|
||||
const end = el.value.length
|
||||
el.setSelectionRange(end, end)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = chatStore.textAreaValue
|
||||
if (!send || !text) return
|
||||
await send(text)
|
||||
inputRef?.current?.focus({ preventScroll: true })
|
||||
clearTextAreaValue()
|
||||
}, [send, inputRef])
|
||||
|
||||
const isDisabled = !textAreaValue.trim() || isSending
|
||||
|
||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||
return
|
||||
e.preventDefault()
|
||||
await handleSubmit()
|
||||
}
|
||||
|
||||
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
value={textAreaValue}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={(e) => {
|
||||
persistTextAreaValue(e.target.value)
|
||||
}}
|
||||
fieldSizing={'content'}
|
||||
style={{
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
maxHeight: '240px',
|
||||
minHeight: `34px`,
|
||||
lineHeight: 1.25,
|
||||
padding: '7px 10px',
|
||||
}}
|
||||
placeholderStyle="strong"
|
||||
spellCheck={false}
|
||||
maxLength={2000}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
aria-label={t('textArea.label')}
|
||||
/>
|
||||
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -11,72 +10,6 @@ const MIN_VISIBLE_TILES = 1
|
||||
const ASPECT_RATIO = 16 / 10
|
||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||
|
||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||
|
||||
interface CarouselLayoutState {
|
||||
orientation: CarouselOrientation
|
||||
maxVisibleTiles: number
|
||||
}
|
||||
|
||||
interface CarouselLayoutObserverProps {
|
||||
asideEl: React.RefObject<HTMLDivElement>
|
||||
orientation?: CarouselOrientation
|
||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||
}
|
||||
|
||||
const CarouselLayoutObserver = ({
|
||||
orientation,
|
||||
asideEl,
|
||||
onLayoutChange,
|
||||
}: CarouselLayoutObserverProps) => {
|
||||
const { width, height } = useSize(asideEl)
|
||||
|
||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||
// container size hovers around a breakpoint. A ref (not state) because
|
||||
// updating it must not trigger a re-render.
|
||||
const prevTilesRef = React.useRef(0)
|
||||
|
||||
const carouselOrientation: CarouselOrientation =
|
||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
|
||||
const availableSpan =
|
||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||
|
||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||
|
||||
let maxVisibleTiles: number
|
||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||
// Within the dead zone: keep the previous count.
|
||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||
} else {
|
||||
maxVisibleTiles = Math.round(tilesThatFit)
|
||||
prevTilesRef.current = tilesThatFit
|
||||
}
|
||||
|
||||
// Apply cosmetic layout output straight to the DOM.
|
||||
useLayoutEffect(() => {
|
||||
const el = asideEl.current
|
||||
if (!el) return
|
||||
el.dataset.lkOrientation = carouselOrientation
|
||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||
|
||||
// Report upward only what the parent actually needs for
|
||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||
useEffect(() => {
|
||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
@@ -107,42 +40,52 @@ export function CarouselLayout({
|
||||
...props
|
||||
}: CarouselLayoutProps) {
|
||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||
const { width, height } = useSize(asideEl)
|
||||
const carouselOrientation = orientation
|
||||
? orientation
|
||||
: height >= width
|
||||
? 'vertical'
|
||||
: 'horizontal'
|
||||
|
||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||
orientation: orientation ?? 'vertical',
|
||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||
})
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
|
||||
// Stable callback + identity check: the parent only re-renders when the
|
||||
// derived layout genuinely changed, not on every resize tick.
|
||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||
setLayout((prev) =>
|
||||
prev.orientation === next.orientation &&
|
||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}, [])
|
||||
const tilesThatFit =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||
maxVisibleTiles = Math.round(prevTiles)
|
||||
} else if (prevTiles !== tilesThatFit) {
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||
asideEl.current.style.setProperty(
|
||||
'--lk-max-visible-tiles',
|
||||
maxVisibleTiles.toString()
|
||||
)
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
|
||||
return (
|
||||
<>
|
||||
<CarouselLayoutObserver
|
||||
asideEl={asideEl}
|
||||
orientation={orientation}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
/>
|
||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||
<aside
|
||||
key={layout.orientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
</>
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,36 +10,6 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
import { PaginationControl } from './PaginationControl'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
|
||||
interface GridLayoutObserverProps {
|
||||
gridEl: React.RefObject<HTMLDivElement>
|
||||
trackCount: number
|
||||
onMaxTilesChange: (maxTiles: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless component that runs the layout calculation in isolation and
|
||||
* reports the resulting tile capacity upward.
|
||||
*
|
||||
* `useGridLayout` re-renders its host on every layout recalculation
|
||||
* (e.g. container resizes). Rendering it in a null child means only this
|
||||
* component churns; the parent `GridLayout` re-renders solely when
|
||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||
*/
|
||||
const GridLayoutObserver = ({
|
||||
gridEl,
|
||||
trackCount,
|
||||
onMaxTilesChange,
|
||||
}: GridLayoutObserverProps) => {
|
||||
const { layout } = useGridLayout(gridEl, trackCount)
|
||||
useEffect(() => {
|
||||
onMaxTilesChange(layout.maxTiles)
|
||||
}, [onMaxTilesChange, layout.maxTiles])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
@@ -67,15 +37,14 @@ export interface GridLayoutProps
|
||||
* @public
|
||||
*/
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = useRef<HTMLDivElement>(null)
|
||||
const [maxTiles, setMaxTiles] = useState(1)
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
|
||||
const elementProps = React.useMemo(
|
||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||
[props]
|
||||
)
|
||||
const pagination = usePagination(maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
@@ -88,13 +57,8 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
data-lk-pagination={pagination.totalPageCount > 1}
|
||||
{...elementProps}
|
||||
>
|
||||
<GridLayoutObserver
|
||||
gridEl={gridEl}
|
||||
trackCount={tracks.length}
|
||||
onMaxTilesChange={setMaxTiles}
|
||||
/>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > maxTiles && (
|
||||
{tracks.length > layout.maxTiles && (
|
||||
<>
|
||||
<PaginationIndicator
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||
|
||||
export const PinAnnouncer = () => {
|
||||
usePinAnnouncements()
|
||||
return null
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
log,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const StageLayout = () => {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ updateOnlyOn: [], onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTracks = tracks
|
||||
.filter(isTrackReference)
|
||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
|
||||
const carouselTracks = tracks.filter(
|
||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||
)
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
useEffect(() => {
|
||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||
if (
|
||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||
lastAutoFocusedScreenShareTrack.current === null
|
||||
) {
|
||||
log.debug('Auto set screen share focus:', {
|
||||
newScreenShareTrack: screenShareTracks[0],
|
||||
})
|
||||
setPinnedTrack(screenShareTracks[0])
|
||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||
} else if (
|
||||
lastAutoFocusedScreenShareTrack.current &&
|
||||
!screenShareTracks.some(
|
||||
(track) =>
|
||||
track.publication.trackSid ===
|
||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||
)
|
||||
) {
|
||||
log.debug('Auto clearing screen share focus.')
|
||||
clearPinnedTrack()
|
||||
lastAutoFocusedScreenShareTrack.current = null
|
||||
}
|
||||
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
||||
const updatedFocusTrack = tracks.find(
|
||||
(tr) =>
|
||||
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
||||
tr.source === pinnedTrackRef.source
|
||||
)
|
||||
if (
|
||||
updatedFocusTrack !== pinnedTrackRef &&
|
||||
isTrackReference(updatedFocusTrack)
|
||||
) {
|
||||
setPinnedTrack(updatedFocusTrack)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
screenShareTracks
|
||||
.map(
|
||||
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
||||
)
|
||||
.join(),
|
||||
pinnedTrackRef?.publication?.trackSid,
|
||||
tracks,
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
return (
|
||||
<>
|
||||
{!pinnedTrackRef ? (
|
||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
<FocusLayout trackRef={pinnedTrackRef} />
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const usePinAnnouncements = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const lastPinnedIdentityRef = useRef<string | null>(null)
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
if (!participant) return tRooms('participants.unknown')
|
||||
return participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
},
|
||||
[tRooms]
|
||||
)
|
||||
|
||||
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
||||
|
||||
useEffect(() => {
|
||||
// 1. unpin
|
||||
if (!pinnedIdentity) {
|
||||
const lastIdentity = lastPinnedIdentityRef.current
|
||||
if (!lastIdentity) return
|
||||
|
||||
const lastParticipant =
|
||||
room.localParticipant.identity === lastIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(lastIdentity)
|
||||
|
||||
announce(
|
||||
lastParticipant?.isLocal
|
||||
? t('self.unpin')
|
||||
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
||||
)
|
||||
|
||||
lastPinnedIdentityRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// 2. same pin → do nothing
|
||||
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
||||
|
||||
// 3. new pin
|
||||
const participant =
|
||||
room.localParticipant.identity === pinnedIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(pinnedIdentity)
|
||||
|
||||
lastPinnedIdentityRef.current = pinnedIdentity
|
||||
|
||||
announce(
|
||||
participant?.isLocal
|
||||
? t('self.pin')
|
||||
: t('pin', { name: getAnnouncementName(participant) })
|
||||
)
|
||||
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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,31 +168,6 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
const removeParticipantNotifications = (participant: Participant) => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
|
||||
@@ -14,5 +14,4 @@ export const NotificationDuration = {
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
ROLE_CHANGED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
@@ -17,5 +17,4 @@ export enum NotificationType {
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -28,6 +28,7 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
state,
|
||||
ref
|
||||
)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
@@ -37,13 +38,18 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
|
||||
const pinParticipant = () => {
|
||||
layoutContext?.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<ClickableToast
|
||||
ref={ref}
|
||||
onPress={(e) => {
|
||||
setPinnedTrack(trackReference)
|
||||
pinParticipant()
|
||||
closeButtonProps.onPress?.(e)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -71,9 +70,6 @@ const renderToast = (
|
||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ScreenShareIcon,
|
||||
useIsEncrypted,
|
||||
} from '@livekit/components-react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||
|
||||
export const ParticipantMetadata = ({
|
||||
displayedName,
|
||||
participant,
|
||||
isScreenShare,
|
||||
}: {
|
||||
displayedName: string
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const isEncrypted = useIsEncrypted(participant)
|
||||
|
||||
return (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
||||
<RaisedHandMetadataWrapper
|
||||
participant={participant}
|
||||
enabled={!isScreenShare}
|
||||
>
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
displayedName={displayedName}
|
||||
isScreenShare={isScreenShare}
|
||||
/>
|
||||
</div>
|
||||
</RaisedHandMetadataWrapper>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React, { CSSProperties } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const participantNameStyles: CSSProperties = {
|
||||
paddingBottom: '0.1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const participantNameScreenShareStyles: CSSProperties = {
|
||||
...participantNameStyles,
|
||||
marginLeft: '0.4rem',
|
||||
}
|
||||
|
||||
export const ParticipantName = React.memo(
|
||||
({
|
||||
displayedName,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
displayedName?: string
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantName.displayName = 'ParticipantName'
|
||||
@@ -1,55 +0,0 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
containerType: 'size',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledAvatarWrapper = styled('div', {
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
aspectRatio: '1 / 1',
|
||||
width: 'min(90cqmin, 160px)',
|
||||
fontSize: 'min(27cqmin, 48px)',
|
||||
'[data-lk-speaking="true"] &': {
|
||||
animation: 'pulse 1s infinite',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantPlaceholderProps = {
|
||||
color: string
|
||||
displayedNamed: string
|
||||
}
|
||||
|
||||
export const ParticipantPlaceholder = React.memo(
|
||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(color),
|
||||
[color]
|
||||
)
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<StyledAvatarWrapper>
|
||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||
</StyledAvatarWrapper>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
||||
import {
|
||||
useRaisedHand,
|
||||
useRaisedHandPosition,
|
||||
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
|
||||
const PositionInQueue = React.memo(
|
||||
({ positionInQueue }: { positionInQueue?: number }) => {
|
||||
if (!positionInQueue) return
|
||||
return (
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PositionInQueue.displayName = 'PositionInQueue'
|
||||
|
||||
const RaisedHandActiveItem = ({
|
||||
participant,
|
||||
targetRef,
|
||||
}: {
|
||||
participant: Participant
|
||||
targetRef: RefObject<HTMLElement | null>
|
||||
}) => {
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant,
|
||||
})
|
||||
useEffect(() => {
|
||||
const el = targetRef.current
|
||||
if (!el) return
|
||||
|
||||
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
||||
el.style.color = 'black'
|
||||
|
||||
return () => {
|
||||
el.style.backgroundColor = ''
|
||||
el.style.color = ''
|
||||
}
|
||||
}, [targetRef, firstInQueue])
|
||||
|
||||
return <PositionInQueue positionInQueue={positionInQueue} />
|
||||
}
|
||||
|
||||
export const RaisedHandMetadataWrapper = ({
|
||||
participant,
|
||||
enabled = true,
|
||||
children,
|
||||
}: {
|
||||
participant: Participant
|
||||
enabled?: boolean
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const { isHandRaised } = useRaisedHand({ participant })
|
||||
const showHand = isHandRaised && enabled
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
transition: 'background 200ms ease',
|
||||
}}
|
||||
>
|
||||
{showHand && (
|
||||
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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'
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiFullscreenLine } from '@remixicon/react'
|
||||
|
||||
export const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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'
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ReactNode, useId, useState } from 'react'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
borderRadius: '8px',
|
||||
margin: '0 .625rem 0.9375rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Header = styled('button', {
|
||||
base: {
|
||||
minHeight: '2.5rem',
|
||||
paddingX: '1.25rem',
|
||||
paddingY: '0.5rem',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
fontSize: '1rem',
|
||||
transition: 'background 200ms',
|
||||
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
||||
_hover: { backgroundColor: 'greyscale.50' },
|
||||
},
|
||||
variants: {
|
||||
isOpen: { false: { borderRadius: '7px' } },
|
||||
},
|
||||
})
|
||||
|
||||
const Chevron = styled(RiArrowUpSLine, {
|
||||
base: { transition: 'transform 200ms', flexShrink: 0 },
|
||||
variants: {
|
||||
isOpen: { false: { transform: 'rotate(180deg)' } },
|
||||
},
|
||||
})
|
||||
|
||||
const List = styled(VStack, {
|
||||
base: {
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: 'greyscale.250',
|
||||
alignItems: 'start',
|
||||
minHeight: 0,
|
||||
flexGrow: 1,
|
||||
paddingY: '0.5rem',
|
||||
paddingX: '1rem',
|
||||
gap: 0,
|
||||
},
|
||||
})
|
||||
|
||||
export type ParticipantsCollapsibleSectionProps = {
|
||||
heading: string
|
||||
count: number
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ParticipantsCollapsibleSection = ({
|
||||
heading,
|
||||
count,
|
||||
action,
|
||||
children,
|
||||
}: ParticipantsCollapsibleSectionProps) => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const listId = useId()
|
||||
return (
|
||||
<Container>
|
||||
<Header
|
||||
type="button"
|
||||
isOpen={isOpen}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listId}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<HStack justify="space-between" width="100%">
|
||||
<span>{heading}</span>
|
||||
<span>{count}</span>
|
||||
</HStack>
|
||||
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
||||
</Header>
|
||||
{isOpen && (
|
||||
<List id={listId} role="list">
|
||||
{action}
|
||||
{children}
|
||||
</List>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { srOnly } from '@/styles/a11y'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiInfinityLine } from '@remixicon/react'
|
||||
|
||||
const badgeStyles = css({
|
||||
position: 'absolute',
|
||||
top: '-.25rem',
|
||||
right: '-.25rem',
|
||||
width: '1.25rem',
|
||||
height: '1.25rem',
|
||||
backgroundColor: 'gray',
|
||||
borderRadius: '50%',
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
zIndex: 1,
|
||||
userSelect: 'none',
|
||||
})
|
||||
|
||||
/**
|
||||
* Isolated so participant join/leave events only re-render the badge,
|
||||
* not the button, tooltip or shortcut registration.
|
||||
*/
|
||||
export const ParticipantsCount = React.memo(
|
||||
({ describedById }: { describedById: string }) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'controls.participants',
|
||||
})
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [],
|
||||
})
|
||||
const count = (remoteParticipants?.length ?? 0) + 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<span id={describedById} className={srOnly}>
|
||||
{t('count', { count })}
|
||||
</span>
|
||||
<div className={badgeStyles} aria-hidden>
|
||||
{count < 100 ? count : <RiInfinityLine size={10} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
ParticipantsCount.displayName = 'ParticipantsCount'
|
||||
@@ -1,103 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
useRemoteParticipants,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantRow } from './ParticipantRow'
|
||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
||||
import { RaisedHandRow } from './RaisedHandRow'
|
||||
|
||||
const JoinedParticipantsSections = () => {
|
||||
const room = useRoomContext()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
|
||||
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
||||
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
||||
// are room-wide and fire for the local participant too, so this hook re-emits
|
||||
// (and we re-render) on local changes, despite its name.
|
||||
//
|
||||
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
||||
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
||||
// local events waking this component up, raising your own hand would not
|
||||
// update the UI. Do not "optimise" these away.
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [
|
||||
RoomEvent.ParticipantNameChanged,
|
||||
RoomEvent.ParticipantAttributesChanged,
|
||||
],
|
||||
})
|
||||
|
||||
const allParticipants = [
|
||||
room.localParticipant,
|
||||
...[...remoteParticipants].sort((a, b) =>
|
||||
(a.name || a.identity).localeCompare(b.name || b.identity)
|
||||
),
|
||||
]
|
||||
|
||||
const participantsWithRaisedHands = allParticipants
|
||||
.filter((p) => !!p.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
||||
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
||||
return (
|
||||
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
||||
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{participantsWithRaisedHands.length > 0 && (
|
||||
<ParticipantsCollapsibleSection
|
||||
heading={t('raisedHands')}
|
||||
count={participantsWithRaisedHands.length}
|
||||
action={
|
||||
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
||||
}
|
||||
>
|
||||
{participantsWithRaisedHands.map((p) => (
|
||||
<RaisedHandRow key={p.identity} participant={p} />
|
||||
))}
|
||||
</ParticipantsCollapsibleSection>
|
||||
)}
|
||||
<ParticipantsCollapsibleSection
|
||||
heading={t('contributors')}
|
||||
count={allParticipants.length}
|
||||
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
||||
>
|
||||
{allParticipants.map((p) => (
|
||||
<ParticipantRow key={p.identity} participant={p} />
|
||||
))}
|
||||
</ParticipantsCollapsibleSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ParticipantsList = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
|
||||
return (
|
||||
<Div overflowY="scroll">
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'greyscale.600',
|
||||
padding: '0 1.5rem',
|
||||
marginBottom: '0.83em',
|
||||
})}
|
||||
>
|
||||
{t('subheading').toUpperCase()}
|
||||
</H>
|
||||
<WaitingParticipantsSection />
|
||||
<JoinedParticipantsSections />
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { RiPushpin2Fill } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { isEqualTrackRef } from '@livekit/components-core'
|
||||
import Source = Track.Source
|
||||
|
||||
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
||||
const cameraTrackRef = useMemo(() => {
|
||||
return {
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
}, [participant])
|
||||
|
||||
const screenShareTrackRef = useMemo(() => {
|
||||
return {
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(Source.ScreenShare),
|
||||
source: Source.ScreenShare,
|
||||
}
|
||||
}, [participant])
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const inFocus =
|
||||
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
||||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
||||
|
||||
if (!inFocus) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '18px',
|
||||
width: '18px',
|
||||
borderRadius: '100%',
|
||||
background: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '-2px',
|
||||
right: '-4px',
|
||||
})}
|
||||
>
|
||||
<RiPushpin2Fill size={14} aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
const ActionButton = ({
|
||||
participant,
|
||||
name,
|
||||
}: {
|
||||
participant: Participant
|
||||
name: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||
|
||||
return (
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
onPress={() => lowerHandParticipant(participant)}
|
||||
aria-label={t('participants.lowerParticipantHand', { name })}
|
||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||
data-attr="participants-lower-hand"
|
||||
>
|
||||
<RiHand />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type HandRaisedListItemProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
const name = participant.name || participant.identity
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
justify="space-between"
|
||||
key={participant.identity}
|
||||
id={participant.identity}
|
||||
className={css({
|
||||
padding: '0.25rem 0',
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0" overflow="hidden">
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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,28 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
||||
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
||||
|
||||
export const WaitingParticipantsSection = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
||||
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
if (waitingParticipants?.length == 0) return
|
||||
|
||||
return (
|
||||
<ParticipantsCollapsibleSection
|
||||
heading={t('title')}
|
||||
count={waitingParticipants.length}
|
||||
>
|
||||
{waitingParticipants.map((p) => (
|
||||
<WaitingParticipantRow
|
||||
key={p.id}
|
||||
participant={p}
|
||||
onAction={handleParticipantEntry}
|
||||
/>
|
||||
))}
|
||||
</ParticipantsCollapsibleSection>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Menu as RACMenu } from 'react-aria-components'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
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 = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
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 (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<PinMenuItem participant={participant} />
|
||||
{canManage && (
|
||||
<RemoveMenuItem
|
||||
identity={participant.identity}
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
{canManage && isAuthenticated && (
|
||||
<PromoteMenuItem
|
||||
identity={participant.identity}
|
||||
isAdmin={isAdmin}
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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,35 +0,0 @@
|
||||
import React from 'react'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RemoveMenuItemProps = {
|
||||
identity: string
|
||||
displayedName?: string
|
||||
}
|
||||
|
||||
export const RemoveMenuItem = React.memo(
|
||||
({ identity, displayedName }: RemoveMenuItemProps) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'participantMenu.remove',
|
||||
})
|
||||
const { removeParticipant } = useRemoveParticipant()
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={t('ariaLabel', { name: displayedName || identity })}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={() => removeParticipant(identity)}
|
||||
>
|
||||
<HStack gap={0.25}>
|
||||
<RiCloseLine size={20} aria-hidden />
|
||||
{t('label')}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RemoveMenuItem.displayName = 'RemoveMenuItem'
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipFocusLayoutProps = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipScreenShareLayoutProps = {
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
|
||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'pictureInPicture',
|
||||
})
|
||||
const { enabled: isScreenSharing } = useTrackToggle({
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
|
||||
return (
|
||||
<Container
|
||||
role="region"
|
||||
aria-label={t('stage')}
|
||||
{...(!isScreenSharing ? { inert: '' } : {})}
|
||||
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||
|
||||
import {
|
||||
useSettingsDialog,
|
||||
SettingsDialogExtendedKey,
|
||||
useTranscriptionLanguage,
|
||||
} from '@/features/settings'
|
||||
@@ -34,7 +35,6 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -48,6 +48,8 @@ export const TranscriptSidePanel = () => {
|
||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
|
||||
@@ -28,6 +28,3 @@ export type ApiRoom = {
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user