Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51a1725c47 | |||
| 1875a394c6 | |||
| 211ba332dc | |||
| 7333c21632 | |||
| 78ebd1a8fd | |||
| 682b69fc11 | |||
| 7a73bf8fc2 | |||
| 1e934957f5 | |||
| 5a7584a3ad | |||
| fb9bf6b08e | |||
| 11b8541005 | |||
| 64607ae8d0 | |||
| c403bbc347 | |||
| 62855fe12c | |||
| f27d451eb6 | |||
| 68792d8019 | |||
| ebb8c14eeb | |||
| 15d43b8d5e | |||
| 1faae96ccd | |||
| 651cc0e5bd | |||
| 0dadd472ff | |||
| be35ee0258 | |||
| bece79f47b | |||
| a499331960 | |||
| d50d167d0a | |||
| e4c7bc0826 | |||
| b5244a5ec0 | |||
| 8cc2cc83c6 | |||
| e591d09b00 | |||
| eeb4dae12d | |||
| 271389d459 | |||
| fe6eefa1f0 | |||
| a276517278 | |||
| 9e9b9015f4 | |||
| 3f0378aada | |||
| 90c88a8bd3 | |||
| 1103902c12 | |||
| 812016d09c | |||
| cadc5c0034 | |||
| b083d837f8 | |||
| db8445f4ab | |||
| 0e5c2be445 | |||
| 0131a65509 | |||
| fb3727e73e | |||
| 3391165e4b | |||
| 0be94aa572 | |||
| b309f91095 | |||
| c50a749293 | |||
| cd9e7c8a1f | |||
| 9d0767ccfe | |||
| 021e52d9eb | |||
| 607a5fc99d | |||
| 756be17cc7 | |||
| 00fa7beebd | |||
| b9d13de591 | |||
| 7896890ffc | |||
| a2043abb51 | |||
| 64bb1b3bb5 | |||
| a1a56402d1 | |||
| 582d3774a4 | |||
| fab08cfaf8 | |||
| 550c48f29e | |||
| 0c64580abf | |||
| 0ad9b7e233 | |||
| b9bcc45cce | |||
| bcd285e368 | |||
| c0ad98eb34 | |||
| 178278235e | |||
| 11664956c7 | |||
| f020979188 | |||
| da5c8d6773 | |||
| 31051cd6c4 | |||
| c51058e6ac | |||
| 3ba913bb21 | |||
| 95116f70e8 | |||
| 5b282b62e7 | |||
| 5e74fce6e2 | |||
| 96b279b350 | |||
| fc4ad562ae | |||
| 480a8df31a |
@@ -1,4 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -48,9 +49,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -92,9 +99,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "meet,secrets"
|
||||
@@ -122,6 +122,9 @@ jobs:
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ creation_rules:
|
||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
|
||||
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
|
||||
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
|
||||
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
|
||||
+16
-25
@@ -1,15 +1,14 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
FROM python:3.12.6-alpine3.20 as base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
@@ -38,12 +37,9 @@ RUN yarn install --frozen-lockfile && \
|
||||
FROM base as link-collector
|
||||
ARG MEET_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
@@ -66,17 +62,14 @@ FROM base as core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -106,9 +99,7 @@ FROM core as backend-development
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall Meet and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
HELMFILE=src/helm/helmfile.yaml
|
||||
|
||||
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
|
||||
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
@@ -16,6 +16,30 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -40,6 +64,7 @@ services:
|
||||
- mailcatcher
|
||||
- redis
|
||||
- nginx
|
||||
- createbuckets
|
||||
- livekit
|
||||
|
||||
celery-dev:
|
||||
@@ -73,6 +98,7 @@ services:
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- createbuckets
|
||||
- livekit
|
||||
|
||||
celery:
|
||||
|
||||
@@ -18,6 +18,9 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": []
|
||||
"matchPackageNames": [
|
||||
"eslint"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
Submodule secrets updated: f5fbc16e6e...8ef9f4513a
@@ -450,7 +450,7 @@ max-branches=12
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
max-parents=10
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
@@ -39,30 +39,30 @@ class IsSelf(IsAuthenticated):
|
||||
return obj == request.user
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""
|
||||
Permissions applying to the room API endpoint.
|
||||
"""
|
||||
# class RoomPermissions(permissions.BasePermission):
|
||||
# """
|
||||
# Permissions applying to the room API endpoint.
|
||||
# """
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Only allow authenticated users for unsafe methods."""
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
# def has_permission(self, request, view):
|
||||
# """Only allow authenticated users for unsafe methods."""
|
||||
# if request.method in permissions.SAFE_METHODS:
|
||||
# return True
|
||||
|
||||
return request.user.is_authenticated
|
||||
# return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Object permissions are only given to administrators of the room."""
|
||||
# def has_object_permission(self, request, view, obj):
|
||||
# """Object permissions are only given to administrators of the room."""
|
||||
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
# if request.method in permissions.SAFE_METHODS:
|
||||
# return True
|
||||
|
||||
user = request.user
|
||||
# user = request.user
|
||||
|
||||
if request.method == "DELETE":
|
||||
return obj.is_owner(user)
|
||||
# if request.method == "DELETE":
|
||||
# return obj.is_owner(user)
|
||||
|
||||
return obj.is_administrator(user)
|
||||
# return obj.is_administrator(user)
|
||||
|
||||
|
||||
class ResourceAccessPermission(permissions.BasePermission):
|
||||
@@ -82,4 +82,15 @@ class ResourceAccessPermission(permissions.BasePermission):
|
||||
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
|
||||
return obj.user == user
|
||||
|
||||
return obj.resource.is_administrator(user)
|
||||
return RoleChoices.is_administrator(obj.resource.get_roles(user))
|
||||
|
||||
|
||||
class AccessPermission(permissions.BasePermission):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated or view.action != "create"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
@@ -87,6 +87,15 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
|
||||
class ListRoomSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Room model for a list API endpoint."""
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "is_public"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
|
||||
class RoomSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Room model for the API."""
|
||||
|
||||
@@ -106,10 +115,10 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if not request:
|
||||
return output
|
||||
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
roles = instance.get_roles(request.user)
|
||||
is_administrator = models.RoleChoices.is_administrator(roles)
|
||||
|
||||
if role is not None:
|
||||
if roles:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
@@ -117,10 +126,10 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
if not is_admin:
|
||||
if not is_administrator:
|
||||
del output["configuration"]
|
||||
|
||||
if role is not None or instance.is_public:
|
||||
if roles or instance.is_public:
|
||||
slug = f"{instance.id!s}".replace("-", "")
|
||||
|
||||
username = request.query_params.get("username", None)
|
||||
@@ -133,6 +142,17 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
),
|
||||
}
|
||||
|
||||
output["is_administrable"] = is_admin
|
||||
output["is_administrable"] = is_administrator
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class RecordingSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Recording for the API."""
|
||||
|
||||
room = ListRoomSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "stopped_at", "status"]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Util to generate S3 authorization headers for object storage access control"""
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our documents' content
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
@@ -1,17 +1,22 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
@@ -25,6 +30,13 @@ from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
RECORDING_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}recordings/({UUID_REGEX:s})/file.mp4/$"
|
||||
)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
@@ -162,7 +174,7 @@ class RoomViewSet(
|
||||
API endpoints to access and perform actions on rooms.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.RoomPermissions]
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
@@ -186,16 +198,10 @@ class RoomViewSet(
|
||||
"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Get Room",
|
||||
properties={"slug": instance.slug},
|
||||
)
|
||||
|
||||
except Http404:
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||
raise
|
||||
|
||||
slug = slugify(self.kwargs["pk"])
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
@@ -209,6 +215,11 @@ class RoomViewSet(
|
||||
},
|
||||
}
|
||||
else:
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Get Room",
|
||||
properties={"slug": instance.slug},
|
||||
)
|
||||
data = self.get_serializer(instance).data
|
||||
|
||||
return drf_response.Response(data)
|
||||
@@ -249,6 +260,17 @@ class RoomViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="start-recording")
|
||||
def start_recording(self, request, *args, **kwargs):
|
||||
"""This view is used to start a recording for a room."""
|
||||
# Check permission first
|
||||
room = self.get_object()
|
||||
recording = room.start_recording()
|
||||
|
||||
return drf_response.Response(
|
||||
{"recording": recording.id}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
@@ -293,3 +315,96 @@ class ResourceAccessViewSet(
|
||||
permission_classes = [permissions.ResourceAccessPermission]
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API endpoints to access and perform actions on recordings.
|
||||
"""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
queryset = models.Recording.objects.all()
|
||||
serializer_class = serializers.RecordingSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict resources returned by the list endpoint to a user's room."""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(room__accesses__user=user)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="stop")
|
||||
def stop(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used to stop a recording. It can be called anonymously as the
|
||||
recording ID will not have been communicated anywhere at the time of recording.
|
||||
"""
|
||||
recording = self.get_object()
|
||||
|
||||
if recording.stopped_at is not None:
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
recording.stopped_at = timezone.now()
|
||||
recording.save()
|
||||
|
||||
# TODO: Generate summary and send to note taking app
|
||||
|
||||
serializer = self.get_serializer(recording)
|
||||
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
|
||||
def retrieve_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a recording file.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
raise exceptions.AuthenticationFailed()
|
||||
|
||||
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
|
||||
match = RECORDING_URL_PATTERN.search(original_url.path)
|
||||
|
||||
try:
|
||||
(pk,) = match.groups()
|
||||
except AttributeError as excpt:
|
||||
raise exceptions.PermissionDenied() from excpt
|
||||
|
||||
# Check permission
|
||||
if not models.Recording.objects.filter(
|
||||
pk=pk, room__accesses__user=request.user
|
||||
).exists():
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
# Generate authorization headers and return an authorization to proceed with the request
|
||||
key = models.Recording(pk=pk).key
|
||||
request = utils.generate_s3_authorization_headers(key)
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -65,3 +65,12 @@ class RoomFactory(ResourceFactory):
|
||||
|
||||
name = factory.Faker("catch_phrase")
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
|
||||
|
||||
class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake recordings for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
|
||||
room = factory.SubFactory(RoomFactory)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-12 11:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_alter_user_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Recording',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('stopped_at', models.DateTimeField(blank=True, null=True, verbose_name='End Time')),
|
||||
('status', models.CharField(choices=[('recording', 'Recording'), ('done', 'Done')], default='recording')),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='core.room', verbose_name='Room')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recording',
|
||||
'verbose_name_plural': 'Recordings',
|
||||
'db_table': 'meet_recording',
|
||||
'ordering': ('created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
+82
-26
@@ -28,14 +28,16 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
@classmethod
|
||||
def check_administrator_role(cls, role):
|
||||
def is_administrator(cls, roles):
|
||||
"""Check if a role is administrator."""
|
||||
return role in [cls.ADMIN, cls.OWNER]
|
||||
return bool(set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}))
|
||||
|
||||
@classmethod
|
||||
def check_owner_role(cls, role):
|
||||
"""Check if a role is owner."""
|
||||
return role == cls.OWNER
|
||||
|
||||
class RecordingStatusChoices(models.TextChoices):
|
||||
"""Recording status choices."""
|
||||
|
||||
RECORDING = "recording", _("Recording")
|
||||
DONE = "done", _("Done")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
@@ -193,34 +195,44 @@ class Resource(BaseModel):
|
||||
except AttributeError:
|
||||
return f"Resource {self.id!s}"
|
||||
|
||||
def get_role(self, user):
|
||||
"""
|
||||
Determine the role of a given user in this resource.
|
||||
"""
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
|
||||
role = None
|
||||
for access in self.accesses.filter(user=user):
|
||||
if access.role == RoleChoices.OWNER:
|
||||
return RoleChoices.OWNER
|
||||
if access.role == RoleChoices.ADMIN:
|
||||
role = RoleChoices.ADMIN
|
||||
if access.role == RoleChoices.MEMBER and role != RoleChoices.ADMIN:
|
||||
role = RoleChoices.MEMBER
|
||||
return role
|
||||
|
||||
def is_administrator(self, user):
|
||||
"""
|
||||
Check if a user is administrator of the resource.
|
||||
|
||||
Users carrying the "owner" role are considered as administrators a fortiori.
|
||||
"""
|
||||
return RoleChoices.check_administrator_role(self.get_role(user))
|
||||
return RoleChoices.is_administrator(self.get_roles(user))
|
||||
|
||||
def is_owner(self, user):
|
||||
"""Check if a user is owner of the resource."""
|
||||
return RoleChoices.check_owner_role(self.get_role(user))
|
||||
return RoleChoices.OWNER in self.get_roles(user)
|
||||
|
||||
def get_roles(self, user):
|
||||
"""Compute the roles a user has in a room."""
|
||||
if not user or not user.is_authenticated:
|
||||
return self.accesses.none()
|
||||
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = self.accesses.filter(user=user).values_list("role", flat=True)
|
||||
except (models.ObjectDoesNotExist, IndexError):
|
||||
roles = self.accesses.none()
|
||||
return roles
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user on the room."""
|
||||
roles = self.get_roles(user)
|
||||
is_owner_or_admin = RoleChoices.is_administrator(roles)
|
||||
|
||||
return {
|
||||
"start_recording": is_owner_or_admin,
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"partial_update": is_owner_or_admin,
|
||||
"retrieve": True,
|
||||
"update": is_owner_or_admin,
|
||||
}
|
||||
|
||||
|
||||
class ResourceAccess(BaseModel):
|
||||
@@ -325,3 +337,47 @@ class Room(Resource):
|
||||
else:
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
def start_recording(self):
|
||||
"""Create a new related recording object to which Livekit will be able to save a file."""
|
||||
return Recording.objects.create(room=self)
|
||||
|
||||
|
||||
class Recording(BaseModel):
|
||||
"""Model for recording meetings that take place in a room"""
|
||||
|
||||
room = models.ForeignKey(
|
||||
Room, on_delete=models.CASCADE, related_name="meetings", verbose_name=_("Room")
|
||||
)
|
||||
stopped_at = models.DateTimeField(verbose_name=_("End Time"), null=True, blank=True)
|
||||
status = models.CharField(
|
||||
choices=RecordingStatusChoices, default=RecordingStatusChoices.RECORDING
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
ordering = ("created_at",)
|
||||
verbose_name = _("Recording")
|
||||
verbose_name_plural = _("Recordings")
|
||||
|
||||
def __str__(self):
|
||||
return _(
|
||||
f"Recording in {self.room.name:s} on {self.created_at:%B %d, %Y at %I:%M %p}"
|
||||
)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Return the path where the recording file will be stored in object storage."""
|
||||
return f"recordings/{self.pk!s}/file.mp4/"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user on the recording."""
|
||||
roles = self.room.get_roles(user)
|
||||
|
||||
return {
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"stop": True,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory
|
||||
from ...models import Recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a recording."""
|
||||
recording = RecordingFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording from a room to which
|
||||
they are not related.
|
||||
"""
|
||||
recording = RecordingFactory()
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_members():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording from a room of which
|
||||
they are only a member.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(
|
||||
room__users=[(user, "member")]
|
||||
) # as user declared in the room but not administrator
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_administrators():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording from a room in which
|
||||
they are administrator.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(room__users=[(user, "administrator")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_owners():
|
||||
"""
|
||||
Authenticated users should be able to delete a recording from a room in which they are
|
||||
directly owner.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(room__users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert Recording.objects.exists() is False
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_list_anonymous():
|
||||
"""Anonymous users should not be able to list recordings."""
|
||||
factories.RecordingFactory(room__is_public=False)
|
||||
factories.RecordingFactory(room__is_public=True)
|
||||
|
||||
response = APIClient().get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated():
|
||||
"""
|
||||
Authenticated users listing recordings, should only see the recordings
|
||||
to which they are related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
factories.RecordingFactory(room__is_public=False)
|
||||
factories.RecordingFactory(room__is_public=True)
|
||||
factories.RecordingFactory(room__is_public=False, room__users=[other_user])
|
||||
|
||||
recording = factories.RecordingFactory(room__is_public=False, room__users=[user])
|
||||
room = recording.room
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
expected_ids = {
|
||||
str(recording.id),
|
||||
}
|
||||
result_ids = {result["id"] for result in results}
|
||||
assert expected_ids == result_ids
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"stopped_at": None,
|
||||
"room": {
|
||||
"id": str(room.id),
|
||||
"is_public": room.is_public,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"status": "recording",
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_recordings_list_pagination(_mock_page_size):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recordings = factories.RecordingFactory.create_batch(3, room__users=[user])
|
||||
recording_ids = [str(r.id) for r in recordings]
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
recording_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"], "http://testserver/api/v1.0/recordings/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
recording_ids.remove(content["results"][0]["id"])
|
||||
assert recording_ids == []
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated_distinct():
|
||||
"""A recording for a public room with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
room__is_public=True, room__users=[user, other_user]
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(recording.id)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Test file downloads API endpoint for users in Meet's core app.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# This is a minimal MP4 file header, which creates a 1-second empty video
|
||||
VIDEO_BYTES = (
|
||||
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42mp41"
|
||||
b"\x00\x00\x00\x08free\x00\x00\x02\xeemdat"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_public", [True, False])
|
||||
def test_api_recordings_retrieve_auth_anonymous(is_public):
|
||||
"""Anonymous users should not be able to retrieve recordings for a room."""
|
||||
room = factories.RoomFactory(is_public=is_public)
|
||||
recording = room.start_recording()
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=VIDEO_BYTES,
|
||||
ContentType="video/mp4",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_public", [True, False])
|
||||
def test_api_recordings_retrieve_auth_authenticated(is_public):
|
||||
"""
|
||||
Authenticated users who are not related to a room should not be able to
|
||||
retrieve recordings for this room.
|
||||
"""
|
||||
room = factories.RoomFactory(is_public=is_public)
|
||||
recording = room.start_recording()
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=VIDEO_BYTES,
|
||||
ContentType="video/mp4",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_public", [True, False])
|
||||
@pytest.mark.parametrize("role", models.RoleChoices.values)
|
||||
def test_api_recordings_retrieve_auth_admin_or_owner(role, is_public):
|
||||
"""
|
||||
Users who are administrator or owner of a room, should be able to retrieve recordings.
|
||||
"""
|
||||
room = factories.RoomFactory(is_public=is_public)
|
||||
recording = room.start_recording()
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
|
||||
# deposit a recording in S3
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=VIDEO_BYTES,
|
||||
ContentType="video/mp4",
|
||||
)
|
||||
|
||||
# verify getting object content from url with authorization headers
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert response.content == VIDEO_BYTES
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: stop.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recording_stop_recording_success():
|
||||
"""Anonymous users can stop a recording just with its UUID."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
now = datetime(2010, 1, 1, tzinfo=timezone.utc)
|
||||
with mock.patch.object(django_timezone, "now", return_value=now):
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
|
||||
|
||||
assert response.status_code == 200
|
||||
recording.refresh_from_db()
|
||||
assert recording.stopped_at == now
|
||||
|
||||
|
||||
def test_api_recording_stop_recording_unknown():
|
||||
"""Trying to stop an unknown recording should return a 404."""
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{uuid4()!s}/stop/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_recording_stop_recording_already_stopped():
|
||||
"""
|
||||
Trying to stop a recording that was already stopped should return a 403 and leave
|
||||
the recording unmodified.
|
||||
"""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
|
||||
|
||||
assert response.status_code == 200
|
||||
recording.refresh_from_db()
|
||||
stopped_at = recording.stopped_at
|
||||
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
|
||||
|
||||
assert response.status_code == 403
|
||||
recording.refresh_from_db()
|
||||
assert recording.stopped_at == stopped_at
|
||||
@@ -286,7 +286,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -360,7 +360,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start-recording.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_rooms_start_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to start a recording for a room."""
|
||||
room = factories.RoomFactory()
|
||||
|
||||
response = APIClient().post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Recording.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_start_recording_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to start a recording for a room
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Recording.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_start_recording_members():
|
||||
"""
|
||||
Users who are members of a room but not administrators should
|
||||
not be allowed to start a recording in this room.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code, 403
|
||||
assert models.Recording.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_api_rooms_start_recording_administrators(role):
|
||||
"""Administrators or owners of a room should be allowed to start a recording in this room."""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory(users=[(user, role)])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Recording.objects.get().room == room
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_api_rooms_start_recording_administrators_of_another(role):
|
||||
"""
|
||||
Being administrator or owner of a room should not grant authorization
|
||||
to update another room.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
factories.RoomFactory(users=[(user, role)])
|
||||
other_room = factories.RoomFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{other_room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code, 403
|
||||
assert models.Recording.objects.exists() is False
|
||||
@@ -86,81 +86,66 @@ def test_models_rooms_is_public_default():
|
||||
assert room.is_public is True
|
||||
|
||||
|
||||
def test_models_recordings_key():
|
||||
"""Check the room key format."""
|
||||
room = RoomFactory()
|
||||
recording = room.start_recording()
|
||||
assert recording.key == f"recordings/{recording.pk!s}/file.mp4/"
|
||||
|
||||
|
||||
# Access rights methods
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_none(django_assert_num_queries):
|
||||
"""Calling access rights methods with None should return None."""
|
||||
"""The `get_roles` method should return an empty list when called with None."""
|
||||
room = RoomFactory()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
assert room.get_role(None) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator(None) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(None) is False
|
||||
assert not list(room.get_roles(None))
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
|
||||
"""Check access rights methods on the room object for an anonymous user."""
|
||||
"""The `get_roles` method should return an empty list for an anonymous user."""
|
||||
user = AnonymousUser()
|
||||
room = RoomFactory()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(user) is False
|
||||
assert not list(room.get_roles(user))
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
|
||||
"""Check access rights methods on the room object for an unrelated user."""
|
||||
"""
|
||||
The `get_roles` method should return an empty list for a user not related to the room.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory()
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
assert not list(room.get_roles(user))
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
|
||||
"""Check access rights methods on the room object for a direct member."""
|
||||
"""Check `get_roles` method on the room object for a direct member."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "member"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
assert list(room.get_roles(user)) == ["member"]
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries):
|
||||
"""The is_administrator method should return True for a direct administrator."""
|
||||
"""Check `get_roles` method on the room object for a direct administrator."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "administrator")])
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "administrator"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
assert list(room.get_roles(user)) == ["administrator"]
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
"""Check access rights methods on the room object for an owner."""
|
||||
"""Check `get_roles` method on the room object for an owner."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "owner"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
assert list(room.get_roles(user)) == ["owner"]
|
||||
|
||||
@@ -5,12 +5,13 @@ from django.urls import include, path
|
||||
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.urls import urlpatterns as oidc_urls
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
@@ -23,6 +24,7 @@ urlpatterns = [
|
||||
[
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,9 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
|
||||
@@ -81,3 +83,31 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our documents' content
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -119,6 +119,25 @@ class Base(Configuration):
|
||||
},
|
||||
}
|
||||
|
||||
# Media
|
||||
AWS_S3_ENDPOINT_URL = values.Value(
|
||||
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
||||
)
|
||||
AWS_S3_ACCESS_KEY_ID = values.Value(
|
||||
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
||||
)
|
||||
AWS_S3_SECRET_ACCESS_KEY = values.Value(
|
||||
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
||||
)
|
||||
AWS_S3_REGION_NAME = values.Value(
|
||||
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
||||
)
|
||||
AWS_STORAGE_BUCKET_NAME = values.Value(
|
||||
"meet-media-storage",
|
||||
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
|
||||
@@ -251,6 +270,19 @@ class Base(Configuration):
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
}
|
||||
|
||||
# Frontend
|
||||
FRONTEND_CONFIGURATION = {
|
||||
"analytics": values.DictValue(
|
||||
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
|
||||
),
|
||||
"support": values.DictValue(
|
||||
{}, environ_name="FRONTEND_SUPPORT", environ_prefix=None
|
||||
),
|
||||
"silence_livekit_debug_logs": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
||||
EMAIL_HOST = values.Value(None)
|
||||
@@ -258,6 +290,7 @@ class Base(Configuration):
|
||||
EMAIL_HOST_PASSWORD = values.Value(None)
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
@@ -535,6 +568,14 @@ class Production(Base):
|
||||
#
|
||||
# In other cases, you should comment the following line to avoid security issues.
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_HSTS_SECONDS = 60
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_REDIRECT_EXEMPT = [
|
||||
"^__lbheartbeat__",
|
||||
"^__heartbeat__",
|
||||
]
|
||||
|
||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""URL configuration for the Meet project"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path, re_path
|
||||
@@ -18,11 +17,7 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns = (
|
||||
urlpatterns
|
||||
+ staticfiles_urlpatterns()
|
||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
)
|
||||
urlpatterns = urlpatterns + staticfiles_urlpatterns()
|
||||
|
||||
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
|
||||
+12
-12
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,34 +25,33 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.10",
|
||||
"boto3==1.35.34",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.8",
|
||||
"redis==5.1.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1",
|
||||
"django==5.1.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.9",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.1",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.9.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.13.0",
|
||||
"sentry-sdk==2.15.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
@@ -70,19 +69,20 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.27.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.7",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-django==4.8.0",
|
||||
"pytest==8.3.2",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.3",
|
||||
"types-requests==2.32.0.20240712",
|
||||
"ruff==0.6.5",
|
||||
"types-requests==2.32.0.20240914",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -32,7 +32,7 @@ WORKDIR /home/frontend
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Meet</title>
|
||||
<title>Visio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+2556
-2843
File diff suppressed because it is too large
Load Diff
+32
-29
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,45 +13,48 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.3.3",
|
||||
"@livekit/components-styles": "1.0.12",
|
||||
"@pandacss/preset-panda": "0.41.0",
|
||||
"@react-aria/toast": "3.0.0-beta.15",
|
||||
"@livekit/components-react": "2.6.5",
|
||||
"@livekit/components-styles": "1.1.3",
|
||||
"@livekit/track-processors": "0.3.2",
|
||||
"@pandacss/preset-panda": "0.46.1",
|
||||
"@react-aria/toast": "3.0.0-beta.16",
|
||||
"@remixicon/react": "4.2.0",
|
||||
"@tanstack/react-query": "5.49.2",
|
||||
"@tanstack/react-query": "5.59.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "23.12.1",
|
||||
"i18next": "23.15.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-parser": "9.0.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.3.1",
|
||||
"react": "18.2.0",
|
||||
"react-aria-components": "1.2.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.1.3",
|
||||
"livekit-client": "2.5.7",
|
||||
"posthog-js": "1.167.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.4.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.0.2",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "1.13.2",
|
||||
"wouter": "3.3.0"
|
||||
"valtio": "2.0.0",
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.41.0",
|
||||
"@tanstack/eslint-plugin-query": "5.49.1",
|
||||
"@tanstack/react-query-devtools": "5.49.2",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/react": "18.3.3",
|
||||
"@pandacss/dev": "0.46.1",
|
||||
"@tanstack/eslint-plugin-query": "5.59.2",
|
||||
"@tanstack/react-query-devtools": "5.59.4",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.13.1",
|
||||
"@typescript-eslint/parser": "7.13.1",
|
||||
"@vitejs/plugin-react": "4.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.8.1",
|
||||
"@typescript-eslint/parser": "8.8.1",
|
||||
"@vitejs/plugin-react": "4.3.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "6.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-refresh": "0.4.7",
|
||||
"postcss": "8.4.39",
|
||||
"eslint-plugin-react-refresh": "0.4.12",
|
||||
"postcss": "8.4.47",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.5.2",
|
||||
"vite": "5.3.1",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.8",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +64,16 @@ const config: Config = {
|
||||
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
|
||||
},
|
||||
active_speaker: {
|
||||
'0%': { height: '4px' },
|
||||
'25%': { height: '8px' },
|
||||
'50%': { height: '6px' },
|
||||
'100%': { height: '16px' },
|
||||
'0%': { height: '25%' },
|
||||
'25%': { height: '45%' },
|
||||
'50%': { height: '20%' },
|
||||
'100%': { height: '55%' },
|
||||
},
|
||||
active_speake_small: {
|
||||
'0%': { height: '4px' },
|
||||
'25%': { height: '6px' },
|
||||
'50%': { height: '4px' },
|
||||
'100%': { height: '8px' },
|
||||
active_speaker_small: {
|
||||
'0%': { height: '20%' },
|
||||
'25%': { height: '25%' },
|
||||
'50%': { height: '18%' },
|
||||
'100%': { height: '25%' },
|
||||
},
|
||||
wave_hand: {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
|
||||
@@ -11,18 +11,15 @@ import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isProduction = import.meta.env.PROD
|
||||
silenceLiveKitLogs(isProduction)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const keys = {
|
||||
user: 'user',
|
||||
room: 'room',
|
||||
config: 'config',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
id: string
|
||||
host: string
|
||||
}
|
||||
support?: {
|
||||
id: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
return fetchApi<ApiConfig>(`config/`)
|
||||
}
|
||||
|
||||
export const useConfig = () => {
|
||||
return useQuery({
|
||||
queryKey: [keys.config],
|
||||
queryFn: fetchConfig,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
support = {},
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LinkButton } from '@/primitives'
|
||||
|
||||
export const Feedback = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
<LinkButton
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
variant="success"
|
||||
target="_blank"
|
||||
@@ -20,6 +20,6 @@ export const Feedback = () => {
|
||||
className={css({ marginLeft: 0.5 })}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import posthog from 'posthog-js'
|
||||
import { ApiUser } from '@/features/auth/api/ApiUser'
|
||||
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
if (posthog._isIdentified()) return
|
||||
const { id, email } = data
|
||||
posthog.identify(id, { email })
|
||||
}
|
||||
|
||||
export const terminateAnalyticsSession = () => {
|
||||
if (!posthog._isIdentified()) return
|
||||
posthog.reset()
|
||||
}
|
||||
|
||||
export type useAnalyticsProps = {
|
||||
id?: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
useEffect(() => {
|
||||
if (!id || !host) return
|
||||
if (posthog.__loaded) return
|
||||
posthog.init(id, {
|
||||
api_host: host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
}, [id, host])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
posthog.capture('$pageview')
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(3600)
|
||||
attemptSilentLogin(300)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { useEffect } from 'react'
|
||||
import { startAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { initializeSupportSession } from '@/features/support/hooks/useSupport'
|
||||
|
||||
/**
|
||||
* returns info about currently logged in user
|
||||
* returns info about currently logged-in user
|
||||
*
|
||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||
*/
|
||||
@@ -12,9 +15,16 @@ export const useUser = () => {
|
||||
const query = useQuery({
|
||||
queryKey: [keys.user],
|
||||
queryFn: fetchUser,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.data) {
|
||||
startAnalyticsSession(query.data)
|
||||
initializeSupportSession(query.data)
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
const isLoggedIn =
|
||||
query.status === 'success' ? query.data !== false : undefined
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
|
||||
@@ -23,7 +23,7 @@ export const JoinMeetingDialog = () => {
|
||||
name="roomId"
|
||||
label={t('joinInputLabel')}
|
||||
description={t('joinInputExample', {
|
||||
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
|
||||
example: 'https://visio.numerique.gouv.fr/azer-tyu-qsdf',
|
||||
})}
|
||||
validate={(value) => {
|
||||
return !isRoomValid(value.trim()) ? (
|
||||
|
||||
@@ -44,6 +44,7 @@ export const LaterMeetingDialog = ({
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="later-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger } from 'react-aria-components'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu, Text } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Centered } from '@/layout/Centered'
|
||||
import { generateRoomId } from '@/features/rooms'
|
||||
import { authUrl, useUser, UserAware } from '@/features/auth'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { useCreateRoom } from '@/features/rooms'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -42,10 +42,12 @@ export const Home = () => {
|
||||
{t('loginToCreateMeeting')}
|
||||
</Text>
|
||||
)}
|
||||
<HStack gap="gutter">
|
||||
<HStack gap="gutter" alignItems="start">
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary">{t('createMeeting')}</Button>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
@@ -57,6 +59,7 @@ export const Home = () => {
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
@@ -69,6 +72,7 @@ export const Home = () => {
|
||||
setLaterRoomId(data.slug)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
@@ -76,12 +80,16 @@ export const Home = () => {
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<Button variant="primary" href={authUrl()}>
|
||||
{t('login', { ns: 'global' })}
|
||||
</Button>
|
||||
<ProConnectButton />
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button variant="primary" outline>
|
||||
<Button
|
||||
variant="primary"
|
||||
outline
|
||||
style={{
|
||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||
}}
|
||||
>
|
||||
{t('joinMeeting')}
|
||||
</Button>
|
||||
<JoinMeetingDialog />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { Div } from '@/primitives'
|
||||
@@ -50,31 +50,32 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [room])
|
||||
|
||||
// fixme - close all related toasters when hands are lowered remotely
|
||||
useEffect(() => {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const handleNotificationReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
prevMetadataStr: string | undefined,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant) {
|
||||
return
|
||||
}
|
||||
if (isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
const notification = decoder.decode(payload)
|
||||
if (!participant) return
|
||||
if (isMobileBrowser()) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
const prevMetadata = JSON.parse(prevMetadataStr || '{}')
|
||||
const metadata = JSON.parse(participant.metadata || '{}')
|
||||
|
||||
if (prevMetadata.raised == metadata.raised) return
|
||||
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
toast.content.participant === participant &&
|
||||
toast.content.type === NotificationType.Raised
|
||||
)
|
||||
if (existingToast && notification === NotificationType.Lowered) {
|
||||
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
toastQueue.close(existingToast.key)
|
||||
return
|
||||
}
|
||||
if (!existingToast && notification === NotificationType.Raised) {
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
triggerNotificationSound(NotificationType.Raised)
|
||||
toastQueue.add(
|
||||
{
|
||||
@@ -86,13 +87,23 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleNotificationReceived)
|
||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleNotificationReceived)
|
||||
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
useEffect(() => {
|
||||
const closeAllToasts = () => {
|
||||
toastQueue.visibleToasts.forEach(({ key }) => toastQueue.close(key))
|
||||
}
|
||||
room.on(RoomEvent.Disconnected, closeAllToasts)
|
||||
return () => {
|
||||
room.off(RoomEvent.Disconnected, closeAllToasts)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
return (
|
||||
<Div position="absolute" bottom={20} right={5} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
|
||||
@@ -12,6 +12,19 @@ const StyledContainer = styled('div', {
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
},
|
||||
variants: {
|
||||
pushToTalk: {
|
||||
true: {
|
||||
height: '46px',
|
||||
width: '58px',
|
||||
borderLeftRadius: 8,
|
||||
borderRightRadius: 0,
|
||||
backgroundColor: '#dbeafe',
|
||||
border: '1px solid #3b82f6',
|
||||
gap: '3px',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledChild = styled('div', {
|
||||
@@ -36,16 +49,32 @@ const StyledChild = styled('div', {
|
||||
},
|
||||
size: {
|
||||
small: {
|
||||
animationName: 'active_speake_small',
|
||||
animationName: 'active_speaker_small',
|
||||
},
|
||||
},
|
||||
pushToTalk: {
|
||||
true: {
|
||||
backgroundColor: 'primary',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
|
||||
export type ActiveSpeakerProps = {
|
||||
isSpeaking: boolean
|
||||
pushToTalk?: boolean
|
||||
}
|
||||
|
||||
export const ActiveSpeaker = ({
|
||||
isSpeaking,
|
||||
pushToTalk,
|
||||
}: ActiveSpeakerProps) => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledContainer pushToTalk={pushToTalk}>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
@@ -53,12 +82,14 @@ export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
@@ -19,6 +18,7 @@ import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -31,6 +31,9 @@ export const Conference = ({
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||
|
||||
const {
|
||||
@@ -79,25 +82,6 @@ export const Conference = ({
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
|
||||
/**
|
||||
* checks for actual click on the leave button instead of
|
||||
* relying on LiveKitRoom onDisconnected because onDisconnected
|
||||
* triggers even on page reload, it's not a user "onLeave" event really.
|
||||
* Here we want to react to the user actually deciding to leave.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const checkOnLeaveClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains('lk-disconnect-button')) {
|
||||
navigateTo('feedback')
|
||||
}
|
||||
}
|
||||
document.body.addEventListener('click', checkOnLeaveClick)
|
||||
return () => {
|
||||
document.body.removeEventListener('click', checkOnLeaveClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
|
||||
@@ -90,6 +90,7 @@ export const InviteDialog = ({
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
|
||||
@@ -16,8 +16,8 @@ export const Join = ({
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
onSubmit={onSubmit}
|
||||
micLabel={t('join.micLabel')}
|
||||
camLabel={t('join.camlabel')}
|
||||
micLabel={t('join.audioinput.label')}
|
||||
camLabel={t('join.videoinput.label')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.userLabel')}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const useLowerHandParticipants = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while lowering hands :', error)
|
||||
throw new Error('An error occurred while lowering hands.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { LocalVideoTrack } from 'livekit-client'
|
||||
import { Text, P, ToggleButton, Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundOptions,
|
||||
ProcessorWrapper,
|
||||
BackgroundTransformer,
|
||||
} from '@livekit/track-processors'
|
||||
|
||||
const Information = styled('div', {
|
||||
base: {
|
||||
backgroundColor: 'orange.50',
|
||||
borderRadius: '4px',
|
||||
padding: '0.75rem 0.75rem',
|
||||
marginTop: '0.8rem',
|
||||
alignItems: 'start',
|
||||
},
|
||||
})
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
LIGHT = 5,
|
||||
NORMAL = 10,
|
||||
}
|
||||
|
||||
export const Effects = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { isCameraEnabled, cameraTrack, localParticipant } =
|
||||
useLocalParticipant()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
|
||||
const getProcessor = () => {
|
||||
return localCameraTrack?.getProcessor() as ProcessorWrapper<BackgroundOptions>
|
||||
}
|
||||
|
||||
const getBlurRadius = (): BlurRadius => {
|
||||
const processor = getProcessor()
|
||||
return (
|
||||
(processor?.transformer as BackgroundTransformer)?.options?.blurRadius ||
|
||||
BlurRadius.NONE
|
||||
)
|
||||
}
|
||||
|
||||
const toggleBlur = async (blurRadius: number) => {
|
||||
if (!isCameraEnabled) await localParticipant.setCameraEnabled(true)
|
||||
if (!localCameraTrack) return
|
||||
|
||||
setProcessorPending(true)
|
||||
|
||||
const processor = getProcessor()
|
||||
const currentBlurRadius = getBlurRadius()
|
||||
|
||||
try {
|
||||
if (blurRadius == currentBlurRadius && processor) {
|
||||
await localCameraTrack.stopProcessor()
|
||||
} else if (!processor) {
|
||||
await localCameraTrack.setProcessor(BackgroundBlur(blurRadius))
|
||||
} else {
|
||||
await processor?.updateTransformerOptions({ blurRadius })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying blur:', error)
|
||||
} finally {
|
||||
setProcessorPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
if (!videoElement) return
|
||||
|
||||
const attachVideoTrack = async () => localCameraTrack?.attach(videoElement)
|
||||
attachVideoTrack()
|
||||
|
||||
return () => {
|
||||
if (!videoElement) return
|
||||
localCameraTrack.detach(videoElement)
|
||||
}
|
||||
}, [localCameraTrack, isCameraEnabled])
|
||||
|
||||
const isSelected = (blurRadius: BlurRadius) => {
|
||||
return isCameraEnabled && getBlurRadius() == blurRadius
|
||||
}
|
||||
|
||||
const tooltipLabel = (blurRadius: BlurRadius) => {
|
||||
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack padding="0 1.5rem">
|
||||
{localCameraTrack && isCameraEnabled ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
width="100%"
|
||||
muted
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
minHeight: '175px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '174px',
|
||||
display: 'flex',
|
||||
backgroundColor: 'black',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<P
|
||||
style={{
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{t('activateCamera')}
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
<Div
|
||||
alignItems={'left'}
|
||||
width={'100%'}
|
||||
style={{
|
||||
border: '1px solid #dadce0',
|
||||
borderRadius: '8px',
|
||||
margin: '0 .625rem',
|
||||
padding: '0.5rem 1rem',
|
||||
}}
|
||||
>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('heading')}
|
||||
</H>
|
||||
{ProcessorWrapper.isSupported ? (
|
||||
<HStack>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
legacyStyle
|
||||
aria-label={tooltipLabel(BlurRadius.LIGHT)}
|
||||
tooltip={tooltipLabel(BlurRadius.LIGHT)}
|
||||
isDisabled={processorPending}
|
||||
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
|
||||
isSelected={isSelected(BlurRadius.LIGHT)}
|
||||
>
|
||||
{t('blur.light')}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
legacyStyle
|
||||
aria-label={tooltipLabel(BlurRadius.NORMAL)}
|
||||
tooltip={tooltipLabel(BlurRadius.NORMAL)}
|
||||
isDisabled={processorPending}
|
||||
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
|
||||
isSelected={isSelected(BlurRadius.NORMAL)}
|
||||
>
|
||||
{t('blur.normal')}
|
||||
</ToggleButton>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text variant="sm">{t('notAvailable')}</Text>
|
||||
)}
|
||||
<Information>
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
⚠︎ {t('experimental')}
|
||||
</Text>
|
||||
</Information>
|
||||
</Div>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { Box, Button, Div } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
|
||||
import { ReactNode } from 'react'
|
||||
import { Effects } from './Effects'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
closeButtonTooltip: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
closeButtonTooltip,
|
||||
}: StyledSidePanelProps) => (
|
||||
<Box
|
||||
size="sm"
|
||||
minWidth="360px"
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
})}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
tooltip={closeButtonTooltip}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<Div overflowY="scroll">{children}</Div>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
if (!sidePanel) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${sidePanel}`)}
|
||||
onClose={() => (layoutStore.sidePanel = null)}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${sidePanel}`),
|
||||
})}
|
||||
>
|
||||
{isParticipantsOpen && <ParticipantsList />}
|
||||
{isEffectsOpen && <Effects />}
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
|
||||
|
||||
export const ChatToggle = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
||||
|
||||
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
@@ -20,10 +20,11 @@ export const ChatToggle = () => {
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
aria-label={t(`controls.chat.${tooltipLabel}`)}
|
||||
tooltip={t(`controls.chat.${tooltipLabel}`)}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isChatOpen}
|
||||
onPress={() => toggleChat()}
|
||||
data-attr={`controls-chat-${tooltipLabel}`}
|
||||
>
|
||||
<RiChat1Line />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -4,30 +4,16 @@ import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
export const HandToggle = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
|
||||
const label = isHandRaised
|
||||
? t('controls.hand.lower')
|
||||
: t('controls.hand.raise')
|
||||
|
||||
const notifyOtherParticipants = (isHandRaised: boolean) => {
|
||||
room.localParticipant.publishData(
|
||||
new TextEncoder().encode(
|
||||
!isHandRaised ? NotificationType.Raised : NotificationType.Lowered
|
||||
),
|
||||
{
|
||||
reliable: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -39,13 +25,11 @@ export const HandToggle = () => {
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
aria-label={label}
|
||||
tooltip={label}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={() => {
|
||||
notifyOtherParticipants(isHandRaised)
|
||||
toggleRaisedHand()
|
||||
}}
|
||||
onPress={() => toggleRaisedHand()}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||
import { Button } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { RiPhoneFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
|
||||
export const LeaveButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
const room = useRoomContext()
|
||||
const connectionState = useConnectionState(room)
|
||||
return (
|
||||
<Button
|
||||
isDisabled={connectionState === ConnectionState.Disconnected}
|
||||
variant={'danger'}
|
||||
tooltip={t('leave')}
|
||||
aria-label={t('leave')}
|
||||
onPress={() => {
|
||||
room
|
||||
.disconnect(true)
|
||||
.catch((e) =>
|
||||
console.error('An error occurred while disconnecting:', e)
|
||||
)
|
||||
.finally(() => {
|
||||
navigateTo('feedback')
|
||||
})
|
||||
}}
|
||||
data-attr="controls-leave"
|
||||
>
|
||||
<RiPhoneFill
|
||||
style={{
|
||||
transform: 'rotate(135deg)',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+34
-28
@@ -1,13 +1,15 @@
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||
import {
|
||||
RiAccountBoxLine,
|
||||
RiFeedbackLine,
|
||||
RiQuestionLine,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { DialogState } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
|
||||
import { DialogState } from './OptionsButton'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = ({
|
||||
@@ -15,8 +17,8 @@ export const OptionsMenuItems = ({
|
||||
}: {
|
||||
onOpenDialog: Dispatch<SetStateAction<DialogState>>
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useWidgetInteraction()
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
@@ -24,29 +26,33 @@ export const OptionsMenuItems = ({
|
||||
width: '300px',
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
|
||||
target="_blank"
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
>
|
||||
<RiQuestionLine size={18} />
|
||||
{t('options.items.support')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
target="_blank"
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
>
|
||||
<RiFeedbackLine size={18} />
|
||||
{t('options.items.feedbacks')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={() => onOpenDialog('settings')}
|
||||
>
|
||||
<RiSettings3Line size={18} />
|
||||
{t('options.items.settings')}
|
||||
</MenuItem>
|
||||
<Section>
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
</Section>
|
||||
<Separator />
|
||||
<Section>
|
||||
<MenuItem
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
target="_blank"
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
>
|
||||
<RiFeedbackLine size={20} />
|
||||
{t('feedbacks')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={() => onOpenDialog('settings')}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
</MenuItem>
|
||||
</Section>
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ export const HandRaisedListItem = ({
|
||||
size="sm"
|
||||
onPress={() => lowerHandParticipant(participant)}
|
||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||
data-attr="participants-lower-hand"
|
||||
>
|
||||
<RiHand />
|
||||
</Button>
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ export const LowerAllHandsButton = ({
|
||||
fullWidth
|
||||
variant="text"
|
||||
onPress={() => lowerHandParticipants(participants)}
|
||||
data-attr="participants-lower-hands"
|
||||
>
|
||||
{t('participants.lowerParticipantsHand')}
|
||||
</Button>
|
||||
|
||||
+1
@@ -78,6 +78,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
? muteParticipant(participant)
|
||||
: setIsAlertOpen(true)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} />
|
||||
|
||||
+45
-80
@@ -1,27 +1,27 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { allParticipantRoomEvents } from '@livekit/components-core'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { Box, Button, Div, H } from '@/primitives'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { participantsStore } from '@/stores/participants'
|
||||
import { Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
|
||||
import { ParticipantListItem } from '@/features/rooms/livekit/components/controls/Participants/ParticipantListItem'
|
||||
import { ParticipantsCollapsableList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList'
|
||||
import { HandRaisedListItem } from '@/features/rooms/livekit/components/controls/Participants/HandRaisedListItem'
|
||||
import { LowerAllHandsButton } from '@/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton'
|
||||
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
|
||||
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
|
||||
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
|
||||
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
|
||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
|
||||
const participants = useParticipants({
|
||||
updateOnlyOn: allParticipantRoomEvents,
|
||||
updateOnlyOn: [
|
||||
RoomEvent.ParticipantNameChanged,
|
||||
...allParticipantRoomEvents,
|
||||
],
|
||||
})
|
||||
|
||||
const sortedRemoteParticipants = participants
|
||||
@@ -44,75 +44,40 @@ export const ParticipantsList = () => {
|
||||
|
||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||
return (
|
||||
<Box
|
||||
size="sm"
|
||||
minWidth="360px"
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
})}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
}}
|
||||
<>
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#5f6368',
|
||||
padding: '0 1.5rem',
|
||||
marginBottom: '0.83em',
|
||||
})}
|
||||
>
|
||||
{t('participants.heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
size="xs"
|
||||
onPress={() => (participantsStore.showParticipants = false)}
|
||||
aria-label={t('participants.closeButton')}
|
||||
tooltip={t('participants.closeButton')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<Div overflowY="scroll">
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#5f6368',
|
||||
padding: '0 1.5rem',
|
||||
marginBottom: '0.83em',
|
||||
})}
|
||||
>
|
||||
{t('participants.subheading').toUpperCase()}
|
||||
</H>
|
||||
{raisedHandParticipants.length > 0 && (
|
||||
<Div marginBottom=".9375rem">
|
||||
<ParticipantsCollapsableList
|
||||
heading={t('participants.raisedHands')}
|
||||
participants={raisedHandParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<HandRaisedListItem participant={participant} />
|
||||
)}
|
||||
action={() => (
|
||||
<LowerAllHandsButton participants={raisedHandParticipants} />
|
||||
)}
|
||||
/>
|
||||
</Div>
|
||||
{t('subheading').toUpperCase()}
|
||||
</H>
|
||||
{raisedHandParticipants.length > 0 && (
|
||||
<Div marginBottom=".9375rem">
|
||||
<ParticipantsCollapsableList
|
||||
heading={t('raisedHands')}
|
||||
participants={raisedHandParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<HandRaisedListItem participant={participant} />
|
||||
)}
|
||||
action={() => (
|
||||
<LowerAllHandsButton participants={raisedHandParticipants} />
|
||||
)}
|
||||
/>
|
||||
</Div>
|
||||
)}
|
||||
<ParticipantsCollapsableList
|
||||
heading={t('contributors')}
|
||||
participants={sortedParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<ParticipantListItem participant={participant} />
|
||||
)}
|
||||
<ParticipantsCollapsableList
|
||||
heading={t('participants.contributors')}
|
||||
participants={sortedParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<ParticipantListItem participant={participant} />
|
||||
)}
|
||||
/>
|
||||
</Div>
|
||||
</Box>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-3
@@ -6,7 +6,7 @@ import { useParticipants } from '@livekit/components-react'
|
||||
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
|
||||
|
||||
export const ParticipantsToggle = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
||||
|
||||
/**
|
||||
* Context could not be used due to inconsistent refresh behavior.
|
||||
@@ -30,10 +30,11 @@ export const ParticipantsToggle = () => {
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
aria-label={t(`controls.participants.${tooltipLabel}`)}
|
||||
tooltip={t(`controls.participants.${tooltipLabel}`)}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={() => toggleParticipants()}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Div, ToggleButton } from '@/primitives'
|
||||
import { RiArrowUpLine, RiCloseFill, RiRectangleLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import React from 'react'
|
||||
|
||||
export const ScreenShareToggle = (
|
||||
props: Omit<
|
||||
UseTrackToggleProps<Track.Source.ScreenShare>,
|
||||
'source' | 'captureOptions'
|
||||
>
|
||||
) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
|
||||
const { buttonProps, enabled } = useTrackToggle({
|
||||
...props,
|
||||
source: Track.Source.ScreenShare,
|
||||
captureOptions: { audio: true, selfBrowserSurface: 'include' },
|
||||
})
|
||||
|
||||
const tooltipLabel = enabled ? 'stop' : 'start'
|
||||
const Icon = enabled ? RiCloseFill : RiArrowUpLine
|
||||
|
||||
// fixme - remove ToggleButton custom styles when we design a proper icon
|
||||
return (
|
||||
<ToggleButton
|
||||
isSelected={enabled}
|
||||
square
|
||||
legacyStyle
|
||||
tooltip={t(tooltipLabel)}
|
||||
onPress={(e) =>
|
||||
buttonProps.onClick?.(
|
||||
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
)
|
||||
}
|
||||
style={{
|
||||
maxWidth: '46px',
|
||||
maxHeight: '46px',
|
||||
}}
|
||||
data-attr={`controls-screenshare-${tooltipLabel}`}
|
||||
>
|
||||
<Div position="relative">
|
||||
<RiRectangleLine size={28} />
|
||||
<Icon
|
||||
size={16}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
</Div>
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useMediaDeviceSelect,
|
||||
useTrackToggle,
|
||||
UseTrackToggleProps,
|
||||
} from '@livekit/components-react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, Menu, MenuList } from '@/primitives'
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
RiArrowDownSLine,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
||||
|
||||
export type ToggleSource = Exclude<
|
||||
Track.Source,
|
||||
Track.Source.ScreenShareAudio | Track.Source.Unknown
|
||||
>
|
||||
|
||||
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
|
||||
|
||||
export type SelectToggleDeviceConfig = {
|
||||
kind: MediaDeviceKind
|
||||
iconOn: RemixiconComponentType
|
||||
iconOff: RemixiconComponentType
|
||||
shortcut?: Shortcut
|
||||
longPress?: Shortcut
|
||||
}
|
||||
|
||||
type SelectToggleDeviceConfigMap = {
|
||||
[key in SelectToggleSource]: SelectToggleDeviceConfig
|
||||
}
|
||||
|
||||
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
|
||||
[Track.Source.Microphone]: {
|
||||
kind: 'audioinput',
|
||||
iconOn: RiMicLine,
|
||||
iconOff: RiMicOffLine,
|
||||
shortcut: {
|
||||
key: 'd',
|
||||
ctrlKey: true,
|
||||
},
|
||||
longPress: {
|
||||
key: 'Space',
|
||||
},
|
||||
},
|
||||
[Track.Source.Camera]: {
|
||||
kind: 'videoinput',
|
||||
iconOn: RiVideoOnLine,
|
||||
iconOff: RiVideoOffLine,
|
||||
shortcut: {
|
||||
key: 'e',
|
||||
ctrlKey: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type SelectToggleDeviceProps<T extends ToggleSource> =
|
||||
UseTrackToggleProps<T> & {
|
||||
onActiveDeviceChange: (deviceId: string) => void
|
||||
source: SelectToggleSource
|
||||
}
|
||||
|
||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
onActiveDeviceChange,
|
||||
...props
|
||||
}: SelectToggleDeviceProps<T>) => {
|
||||
const config = selectToggleDeviceConfig[props.source]
|
||||
if (!config) {
|
||||
throw new Error('Invalid source')
|
||||
}
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const trackProps = useTrackToggle(props)
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: config.kind })
|
||||
|
||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
||||
|
||||
return (
|
||||
<HStack gap={0}>
|
||||
<ToggleDevice {...trackProps} config={config} />
|
||||
<Menu>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
>
|
||||
<RiArrowDownSLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={devices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveMediaDevice(value as string)
|
||||
onActiveDeviceChange(value as string)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
|
||||
|
||||
export type ToggleDeviceProps = {
|
||||
enabled: boolean
|
||||
toggle: () => void
|
||||
config: SelectToggleDeviceConfig
|
||||
}
|
||||
|
||||
export const ToggleDevice = ({
|
||||
config,
|
||||
enabled,
|
||||
toggle,
|
||||
}: ToggleDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { kind, shortcut, iconOn, iconOff, longPress } = config
|
||||
|
||||
const [pushToTalk, setPushToTalk] = useState(false)
|
||||
|
||||
const onKeyDown = () => {
|
||||
if (pushToTalk || enabled) return
|
||||
toggle()
|
||||
setPushToTalk(true)
|
||||
}
|
||||
const onKeyUp = () => {
|
||||
if (!pushToTalk) return
|
||||
toggle()
|
||||
setPushToTalk(false)
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
|
||||
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `join.${kind}`,
|
||||
})
|
||||
return shortcut ? appendShortcutLabel(label, shortcut) : label
|
||||
}, [enabled, kind, shortcut, t])
|
||||
|
||||
const Icon = enabled ? iconOn : iconOff
|
||||
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
|
||||
if (kind === 'audioinput' && pushToTalk) {
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
isSelected={enabled}
|
||||
variant={enabled ? undefined : 'danger'}
|
||||
toggledStyles={false}
|
||||
onPress={() => toggle()}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={toggleLabel}
|
||||
groupPosition="left"
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
|
||||
// Issue: 'allRemoteParticipantRoomEvents' is not exposed or importable. One event is missing
|
||||
// to trigger the real-time update of participants when they change their name.
|
||||
// This code is duplicated from LiveKit.
|
||||
export const allRemoteParticipantRoomEvents = [
|
||||
RoomEvent.ConnectionStateChanged,
|
||||
RoomEvent.RoomMetadataChanged,
|
||||
|
||||
RoomEvent.ActiveSpeakersChanged,
|
||||
RoomEvent.ConnectionQualityChanged,
|
||||
|
||||
RoomEvent.ParticipantConnected,
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
RoomEvent.ParticipantPermissionsChanged,
|
||||
RoomEvent.ParticipantMetadataChanged,
|
||||
RoomEvent.ParticipantNameChanged, // This element is missing in LiveKit and causes problems
|
||||
|
||||
RoomEvent.TrackMuted,
|
||||
RoomEvent.TrackUnmuted,
|
||||
RoomEvent.TrackPublished,
|
||||
RoomEvent.TrackUnpublished,
|
||||
RoomEvent.TrackStreamStateChanged,
|
||||
RoomEvent.TrackSubscriptionFailed,
|
||||
RoomEvent.TrackSubscriptionPermissionChanged,
|
||||
RoomEvent.TrackSubscriptionStatusChanged,
|
||||
]
|
||||
|
||||
export const allParticipantRoomEvents = [
|
||||
...allRemoteParticipantRoomEvents,
|
||||
RoomEvent.LocalTrackPublished,
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
]
|
||||
@@ -7,6 +7,7 @@ type useRaisedHandProps = {
|
||||
}
|
||||
|
||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
// fixme - refactor this part to rely on attributes
|
||||
const { metadata } = useParticipantInfo({ participant })
|
||||
const parsedMetadata = JSON.parse(metadata || '{}')
|
||||
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
import { useLayoutContext } from '@livekit/components-react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { participantsStore } from '@/stores/participants.ts'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export const useWidgetInteraction = () => {
|
||||
const { dispatch, state } = useLayoutContext().widget
|
||||
|
||||
const participantsSnap = useSnapshot(participantsStore)
|
||||
const isParticipantsOpen = participantsSnap.showParticipants
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
const isParticipantsOpen = sidePanel == 'participants'
|
||||
const isEffectsOpen = sidePanel == 'effects'
|
||||
|
||||
const toggleParticipants = () => {
|
||||
if (dispatch && state?.showChat) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
participantsStore.showParticipants = !isParticipantsOpen
|
||||
layoutStore.sidePanel = isParticipantsOpen ? null : 'participants'
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
if (isParticipantsOpen) {
|
||||
participantsStore.showParticipants = false
|
||||
if (isParticipantsOpen || isEffectsOpen) {
|
||||
layoutStore.sidePanel = null
|
||||
}
|
||||
if (dispatch) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
if (dispatch && state?.showChat) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
layoutStore.sidePanel = isEffectsOpen ? null : 'effects'
|
||||
}
|
||||
|
||||
return {
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
isChatOpen: state?.showChat,
|
||||
unreadMessages: state?.unreadMessages,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@ import * as React from 'react'
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
|
||||
import {
|
||||
DisconnectButton,
|
||||
LeaveIcon,
|
||||
MediaDeviceMenu,
|
||||
TrackToggle,
|
||||
useMaybeLayoutContext,
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
@@ -15,11 +11,13 @@ import {
|
||||
import { mergeProps } from '@/utils/mergeProps.ts'
|
||||
import { StartMediaButton } from '../components/controls/StartMediaButton'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { OptionsButton } from '../components/controls/Options/OptionsButton'
|
||||
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
|
||||
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
|
||||
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
||||
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle'
|
||||
import { ChatToggle } from '../components/controls/ChatToggle'
|
||||
import { HandToggle } from '../components/controls/HandToggle'
|
||||
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
|
||||
import { LeaveButton } from '../components/controls/LeaveButton'
|
||||
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
@@ -38,7 +36,7 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
controls?: ControlBarControls
|
||||
/**
|
||||
* If `true`, the user's device choices will be persisted.
|
||||
* This will enables the user to have the same device choices when they rejoin the room.
|
||||
* This will enable the user to have the same device choices when they rejoin the room.
|
||||
* @defaultValue true
|
||||
* @alpha
|
||||
*/
|
||||
@@ -67,7 +65,6 @@ export function ControlBar({
|
||||
onDeviceError,
|
||||
...props
|
||||
}: ControlBarProps) {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isChatOpen, setIsChatOpen] = React.useState(false)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
React.useEffect(() => {
|
||||
@@ -83,26 +80,8 @@ export function ControlBar({
|
||||
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
|
||||
variation ??= defaultVariation
|
||||
|
||||
const showIcon = React.useMemo(
|
||||
() => variation === 'minimal' || variation === 'verbose',
|
||||
[variation]
|
||||
)
|
||||
const showText = React.useMemo(
|
||||
() => variation === 'textOnly' || variation === 'verbose',
|
||||
[variation]
|
||||
)
|
||||
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
|
||||
const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false)
|
||||
|
||||
const onScreenShareChange = React.useCallback(
|
||||
(enabled: boolean) => {
|
||||
setIsScreenShareEnabled(enabled)
|
||||
},
|
||||
[setIsScreenShareEnabled]
|
||||
)
|
||||
|
||||
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
|
||||
|
||||
const {
|
||||
@@ -126,72 +105,38 @@ export function ControlBar({
|
||||
|
||||
return (
|
||||
<div {...htmlProps}>
|
||||
<div className="lk-button-group">
|
||||
<TrackToggle
|
||||
source={Track.Source.Microphone}
|
||||
showIcon={showIcon}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
>
|
||||
{showText && t('controls.microphone')}
|
||||
</TrackToggle>
|
||||
<div className="lk-button-group-menu">
|
||||
<MediaDeviceMenu
|
||||
kind="audioinput"
|
||||
onActiveDeviceChange={(_kind, deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lk-button-group">
|
||||
<TrackToggle
|
||||
source={Track.Source.Camera}
|
||||
showIcon={showIcon}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
>
|
||||
{showText && t('controls.camera')}
|
||||
</TrackToggle>
|
||||
<div className="lk-button-group-menu">
|
||||
<MediaDeviceMenu
|
||||
kind="videoinput"
|
||||
onActiveDeviceChange={(_kind, deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
{browserSupportsScreenSharing && (
|
||||
<TrackToggle
|
||||
source={Track.Source.ScreenShare}
|
||||
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
|
||||
showIcon={showIcon}
|
||||
onChange={onScreenShareChange}
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.ScreenShare, error })
|
||||
}
|
||||
>
|
||||
{showText &&
|
||||
t(
|
||||
isScreenShareEnabled
|
||||
? 'controls.stopScreenShare'
|
||||
: 'controls.shareScreen'
|
||||
)}
|
||||
</TrackToggle>
|
||||
/>
|
||||
)}
|
||||
<HandToggle />
|
||||
<ChatToggle />
|
||||
<ParticipantsToggle />
|
||||
<OptionsButton />
|
||||
<DisconnectButton>
|
||||
{showIcon && <LeaveIcon />}
|
||||
{showText && t('controls.leave')}
|
||||
</DisconnectButton>
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -30,11 +30,11 @@ import {
|
||||
import { ControlBar } from './ControlBar'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { participantsStore } from '@/stores/participants'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
@@ -172,8 +172,8 @@ export function VideoConference({
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const participantsSnap = useSnapshot(participantsStore)
|
||||
const showParticipants = participantsSnap.showParticipants
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
return (
|
||||
<div className="lk-video-conference" {...props}>
|
||||
@@ -223,7 +223,7 @@ export function VideoConference({
|
||||
messageEncoder={chatMessageEncoder}
|
||||
messageDecoder={chatMessageDecoder}
|
||||
/>
|
||||
{showParticipants && <ParticipantsList />}
|
||||
{sidePanel && <SidePanel />}
|
||||
</LayoutWrapper>
|
||||
<ControlBar />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { Conference } from '../components/Conference'
|
||||
import { Join } from '../components/Join'
|
||||
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
@@ -19,6 +20,8 @@ export const Room = () => {
|
||||
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
|
||||
const skipJoinScreen = isLoggedIn && mode === 'create'
|
||||
|
||||
useKeyboardShortcuts()
|
||||
|
||||
const clearRouterState = () => {
|
||||
if (window?.history?.state) {
|
||||
window.history.replaceState({}, '')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
|
||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||
|
||||
@@ -28,9 +29,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
) : (
|
||||
<>
|
||||
<P>{t('account.youAreNotLoggedIn')}</P>
|
||||
<P>
|
||||
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
|
||||
</P>
|
||||
<ProConnectButton />
|
||||
</>
|
||||
)}
|
||||
<H lvl={2}>{t('language.heading')}</H>
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
usePersistentUserChoices,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useState } from 'react'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
|
||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -58,9 +59,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
) : (
|
||||
<>
|
||||
<P>{t('account.youAreNotLoggedIn')}</P>
|
||||
<P>
|
||||
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
|
||||
</P>
|
||||
<ProConnectButton />
|
||||
</>
|
||||
)}
|
||||
<HStack
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Shortcut = {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { formatShortcutKey } from './utils'
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const shortcutsSnap = useSnapshot(keyboardShortcutsStore)
|
||||
|
||||
useEffect(() => {
|
||||
// This approach handles basic shortcuts but isn't comprehensive.
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
})
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
e.preventDefault()
|
||||
shortcut()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [shortcutsSnap])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export type useLongPressProps = {
|
||||
keyCode?: string
|
||||
onKeyDown: () => void
|
||||
onKeyUp: () => void
|
||||
longPressThreshold?: number
|
||||
}
|
||||
|
||||
export const useLongPress = ({
|
||||
keyCode,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
longPressThreshold = 300,
|
||||
}: useLongPressProps) => {
|
||||
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code != keyCode || timeoutIdRef.current) return
|
||||
timeoutIdRef.current = setTimeout(() => {
|
||||
onKeyDown()
|
||||
}, longPressThreshold)
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code != keyCode || !timeoutIdRef.current) return
|
||||
clearTimeout(timeoutIdRef.current)
|
||||
timeoutIdRef.current = null
|
||||
onKeyUp()
|
||||
}
|
||||
|
||||
if (!keyCode) return
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
export default useLongPress
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export type useRegisterKeyboardShortcutProps = {
|
||||
shortcut?: Shortcut
|
||||
handler: () => void
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
shortcut,
|
||||
handler,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
useEffect(() => {
|
||||
if (!shortcut) return
|
||||
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
|
||||
}, [handler, shortcut])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export const CTRL = 'ctrl'
|
||||
|
||||
export const formatShortcutKey = (shortcut: Shortcut) => {
|
||||
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
|
||||
return shortcut.key.toUpperCase()
|
||||
}
|
||||
|
||||
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
|
||||
if (!shortcut.key) return
|
||||
let formattedKeyLabel = shortcut.key.toLowerCase()
|
||||
if (shortcut.ctrlKey) {
|
||||
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
|
||||
}
|
||||
return `${label} (${formattedKeyLabel})`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { ApiUser } from '@/features/auth/api/ApiUser'
|
||||
|
||||
export const initializeSupportSession = (user: ApiUser) => {
|
||||
if (!Crisp.isCrispInjected()) return
|
||||
const { id, email } = user
|
||||
Crisp.setTokenId(`meet-${id}`)
|
||||
Crisp.user.setEmail(email)
|
||||
}
|
||||
|
||||
export const terminateSupportSession = () => {
|
||||
if (!Crisp.isCrispInjected()) return
|
||||
Crisp.setTokenId()
|
||||
Crisp.session.reset()
|
||||
}
|
||||
|
||||
export type useSupportProps = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
// Configure Crisp chat for real-time support across all pages.
|
||||
export const useSupport = ({ id }: useSupportProps) => {
|
||||
useEffect(() => {
|
||||
if (!id || Crisp.isCrispInjected()) return
|
||||
Crisp.configure(id)
|
||||
Crisp.setHideOnMobile(true)
|
||||
}, [id])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -2,13 +2,16 @@ import { Link } from 'wouter'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { A, Text, Button } from '@/primitives'
|
||||
import { Text, Button } from '@/primitives'
|
||||
import { SettingsButton } from '@/features/settings'
|
||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||
import { Feedback } from '@/components/Feedback'
|
||||
import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -63,7 +66,7 @@ export const Header = () => {
|
||||
<nav>
|
||||
<Stack gap={1} direction="row" align="center">
|
||||
{isLoggedIn === false && !isHome && (
|
||||
<A href={authUrl()}>{t('login')}</A>
|
||||
<ProConnectButton hint={false} />
|
||||
)}
|
||||
{!!user && (
|
||||
<Menu>
|
||||
@@ -79,6 +82,8 @@ export const Header = () => {
|
||||
items={[{ value: 'logout', label: t('logout') }]}
|
||||
onAction={(value) => {
|
||||
if (value === 'logout') {
|
||||
terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"app": "Meet",
|
||||
"app": "Visio",
|
||||
"backToHome": "",
|
||||
"cancel": "",
|
||||
"closeDialog": "",
|
||||
@@ -12,7 +12,11 @@
|
||||
},
|
||||
"loading": "",
|
||||
"loggedInUserTooltip": "",
|
||||
"login": "Anmelden",
|
||||
"login": {
|
||||
"buttonLabel": "",
|
||||
"linkLabel": "",
|
||||
"link": ""
|
||||
},
|
||||
"logout": "",
|
||||
"notFound": {
|
||||
"heading": ""
|
||||
|
||||
@@ -4,11 +4,27 @@
|
||||
"heading": ""
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "",
|
||||
"videoinput": {
|
||||
"choose": "",
|
||||
"disable": "",
|
||||
"enable": "",
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "",
|
||||
"disable": "",
|
||||
"enable": "",
|
||||
"label": ""
|
||||
},
|
||||
"heading": "",
|
||||
"joinLabel": "",
|
||||
"micLabel": "",
|
||||
"userLabel": ""
|
||||
"joinMeeting": "",
|
||||
"toggleOff": "",
|
||||
"toggleOn": "",
|
||||
"userLabel": "",
|
||||
"usernameHint": "",
|
||||
"usernameLabel": ""
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
@@ -37,6 +53,10 @@
|
||||
"raise": "",
|
||||
"lower": ""
|
||||
},
|
||||
"screenShare": {
|
||||
"start": "",
|
||||
"stop": ""
|
||||
},
|
||||
"leave": "",
|
||||
"participants": {
|
||||
"open": "",
|
||||
@@ -49,13 +69,35 @@
|
||||
"feedbacks": "",
|
||||
"support": "",
|
||||
"settings": "",
|
||||
"username": ""
|
||||
"username": "",
|
||||
"effects": ""
|
||||
}
|
||||
},
|
||||
"participants": {
|
||||
"effects": {
|
||||
"activateCamera": "",
|
||||
"notAvailable": "",
|
||||
"heading": "",
|
||||
"blur": {
|
||||
"light": "",
|
||||
"normal": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
},
|
||||
"experimental": ""
|
||||
},
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "",
|
||||
"effects": ""
|
||||
},
|
||||
"content": {
|
||||
"participants": "",
|
||||
"effects": ""
|
||||
},
|
||||
"closeButton": ""
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "",
|
||||
"closeButton": "",
|
||||
"contributors": "",
|
||||
"collapsable": {
|
||||
"open": "",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"app": "Meet",
|
||||
"app": "Visio",
|
||||
"backToHome": "Back to homescreen",
|
||||
"cancel": "Cancel",
|
||||
"closeDialog": "Close dialog",
|
||||
"error": {
|
||||
"heading": "An error occured while loading the page"
|
||||
"heading": "An error occurred while loading the page"
|
||||
},
|
||||
"feedbackAlert": "Give us feedback",
|
||||
"forbidden": {
|
||||
@@ -12,7 +12,11 @@
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"loggedInUserTooltip": "Logged in as…",
|
||||
"login": "Login",
|
||||
"login": {
|
||||
"buttonLabel": "Login with ProConnect",
|
||||
"linkLabel": "What is ProConnect? - new tab",
|
||||
"link": "What is ProConnect?"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"notFound": {
|
||||
"heading": "Page not found"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"createMeeting": "Create a meeting",
|
||||
"heading": "Welcome in Meet",
|
||||
"heading": "Welcome in Visio",
|
||||
"intro": "Work easily, from anywhere.",
|
||||
"joinInputError": "Use a meeting link or code. Examples:",
|
||||
"joinInputExample": "URL or 10-letter code",
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"feedback": {
|
||||
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
|
||||
"heading": "Help us improve Meet"
|
||||
"heading": "Help us improve Visio"
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "Camera",
|
||||
"heading": "Join the meeting",
|
||||
"videoinput": {
|
||||
"choose": "Select camera",
|
||||
"disable": "Disable camera",
|
||||
"enable": "Enable camera",
|
||||
"label": "Camera",
|
||||
"placeholder": "Enable camera to see the preview"
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Select microphone",
|
||||
"disable": "Disable microphone",
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Verify your settings",
|
||||
"joinLabel": "Join",
|
||||
"micLabel": "Microphone",
|
||||
"userLabel": "Your name"
|
||||
"joinMeeting": "Join meeting",
|
||||
"toggleOff": "Click to turn off",
|
||||
"toggleOn": "Click to turn on",
|
||||
"userLabel": "",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name"
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -27,8 +43,6 @@
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
"shareScreen": "Share screen",
|
||||
"stopScreenShare": "Stop screen share",
|
||||
"chat": {
|
||||
"open": "Close the chat",
|
||||
"closed": "Open the chat"
|
||||
@@ -37,6 +51,10 @@
|
||||
"raise": "Raise hand",
|
||||
"lower": "Lower hand"
|
||||
},
|
||||
"screenShare": {
|
||||
"start": "Share screen",
|
||||
"stop": "Stop screen share"
|
||||
},
|
||||
"leave": "Leave",
|
||||
"participants": {
|
||||
"open": "Hide everyone",
|
||||
@@ -49,13 +67,35 @@
|
||||
"feedbacks": "Give us feedbacks",
|
||||
"support": "Get Help on Tchap",
|
||||
"settings": "Settings",
|
||||
"username": "Update Your Name"
|
||||
"username": "Update Your Name",
|
||||
"effects": "Apply effects"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
|
||||
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome :(",
|
||||
"heading": "Blur",
|
||||
"blur": {
|
||||
"light": "Light blur",
|
||||
"normal": "Blur",
|
||||
"apply": "Enable blur",
|
||||
"clear": "Disable blur"
|
||||
},
|
||||
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
|
||||
},
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effects"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
},
|
||||
"participants": {
|
||||
"heading": "Participants",
|
||||
"subheading": "In room",
|
||||
"closeButton": "Hide participants",
|
||||
"you": "You",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
|
||||
"heading": "Account",
|
||||
"youAreNotLoggedIn": "You are not logged in.",
|
||||
"nameLabel": "Votre Nom",
|
||||
"nameLabel": "Your Name",
|
||||
"authentication": "Authentication"
|
||||
},
|
||||
"audio": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"app": "Meet",
|
||||
"app": "Visio",
|
||||
"backToHome": "Retour à l'accueil",
|
||||
"cancel": "Annuler",
|
||||
"closeDialog": "Fermer la fenêtre modale",
|
||||
@@ -12,7 +12,11 @@
|
||||
},
|
||||
"loading": "Chargement…",
|
||||
"loggedInUserTooltip": "Connecté en tant que…",
|
||||
"login": "Se connecter",
|
||||
"login": {
|
||||
"buttonLabel": "S'identifier avec ProConnect",
|
||||
"linkLabel": "Qu'est-ce que ProConnect ? - nouvelle fenêtre",
|
||||
"link": "Qu'est-ce que ProConnect ?"
|
||||
},
|
||||
"logout": "Se déconnecter",
|
||||
"notFound": {
|
||||
"heading": "Page introuvable"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"createMeeting": "Créer une réunion",
|
||||
"heading": "Meet",
|
||||
"heading": "Visio",
|
||||
"intro": "Collaborez en toute simplicité, où que vous soyez.",
|
||||
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
|
||||
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"feedback": {
|
||||
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
|
||||
"heading": "Aidez-nous à améliorer Meet"
|
||||
"heading": "Aidez-nous à améliorer Visio"
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "Webcam",
|
||||
"heading": "Rejoindre la réunion",
|
||||
"videoinput": {
|
||||
"choose": "Choisir la webcam",
|
||||
"disable": "Désactiver la webcam",
|
||||
"enable": "Activer la webcam",
|
||||
"label": "Webcam",
|
||||
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Choisir le micro",
|
||||
"disable": "Désactiver le micro",
|
||||
"enable": "Activer le micro",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Vérifiez vos paramètres",
|
||||
"joinLabel": "Rejoindre",
|
||||
"micLabel": "Micro",
|
||||
"userLabel": "Votre nom"
|
||||
"joinMeeting": "Rejoindre la réjoindre",
|
||||
"toggleOff": "Cliquez pour désactiver",
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"userLabel": "",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom"
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
@@ -27,8 +43,6 @@
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
"shareScreen": "Partager l'écran",
|
||||
"stopScreenShare": "Arrêter le partage",
|
||||
"chat": {
|
||||
"open": "Masquer le chat",
|
||||
"closed": "Afficher le chat"
|
||||
@@ -37,6 +51,10 @@
|
||||
"raise": "Lever la main",
|
||||
"lower": "Baisser la main"
|
||||
},
|
||||
"screenShare": {
|
||||
"start": "Partager l'écran",
|
||||
"stop": "Arrêter le partage"
|
||||
},
|
||||
"leave": "Quitter",
|
||||
"participants": {
|
||||
"open": "Masquer les participants",
|
||||
@@ -49,13 +67,35 @@
|
||||
"feedbacks": "Partager votre avis",
|
||||
"support": "Obtenir de l'aide sur Tchap",
|
||||
"settings": "Paramètres",
|
||||
"username": "Choisir votre nom"
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Appliquer des effets"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
|
||||
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome :(",
|
||||
"heading": "Flou",
|
||||
"blur": {
|
||||
"light": "Léger flou",
|
||||
"normal": "Flou",
|
||||
"apply": "Appliquer le flou",
|
||||
"clear": "Désactiver le flou"
|
||||
},
|
||||
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
|
||||
},
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effets"
|
||||
},
|
||||
"content": {
|
||||
"participants": "les participants",
|
||||
"effects": "les effets"
|
||||
},
|
||||
"closeButton": "Masquer {{content}}"
|
||||
},
|
||||
"participants": {
|
||||
"heading": "Participants",
|
||||
"subheading": "Dans la réunion",
|
||||
"closeButton": "Masquer les participants",
|
||||
"you": "Vous",
|
||||
"contributors": "Contributeurs",
|
||||
"collapsable": {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
Button as RACButton,
|
||||
type ButtonProps as RACButtonsProps,
|
||||
Link,
|
||||
LinkProps,
|
||||
} from 'react-aria-components'
|
||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||
@@ -12,25 +10,12 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
RACButtonsProps &
|
||||
TooltipWrapperProps
|
||||
|
||||
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
LinkProps &
|
||||
TooltipWrapperProps
|
||||
|
||||
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
|
||||
|
||||
export const Button = ({
|
||||
tooltip,
|
||||
tooltipType = 'instant',
|
||||
...props
|
||||
}: ButtonOrLinkProps) => {
|
||||
}: ButtonProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
if ((props as LinkButtonProps).href !== undefined) {
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<Link className={buttonRecipe(variantProps)} {...componentProps} />
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
|
||||
@@ -134,10 +134,11 @@ export const Checkbox = ({
|
||||
>
|
||||
<StyledCheckbox {...props}>
|
||||
{(renderProps) => {
|
||||
renderProps.isInvalid && !!props.validate
|
||||
? setError(props.validate(renderProps.isSelected))
|
||||
: setError(null)
|
||||
|
||||
if (renderProps.isInvalid && !!props.validate) {
|
||||
setError(props.validate(renderProps.isSelected))
|
||||
} else {
|
||||
setError(null)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="mt-Checkbox-checkbox">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Link, LinkProps } from 'react-aria-components'
|
||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||
|
||||
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
LinkProps &
|
||||
TooltipWrapperProps
|
||||
|
||||
export const LinkButton = ({
|
||||
tooltip,
|
||||
tooltipType = 'instant',
|
||||
...props
|
||||
}: LinkButtonProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<Link className={buttonRecipe(variantProps)} {...componentProps} />
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const MenuList = <T extends string | number = string>({
|
||||
const label = typeof item === 'string' ? item : item.label
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuItemRecipe()}
|
||||
className={menuItemRecipe({ extraPadding: true })}
|
||||
key={value}
|
||||
id={value as string}
|
||||
onAction={() => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
|
||||
export const Separator = styled(RACSeparator, {
|
||||
base: {
|
||||
height: '1px',
|
||||
background: 'gray.400',
|
||||
margin: '4px 0',
|
||||
},
|
||||
})
|
||||
@@ -9,6 +9,7 @@ export { Badge } from './Badge'
|
||||
export { Bold } from './Bold'
|
||||
export { Box } from './Box'
|
||||
export { Button } from './Button'
|
||||
export { LinkButton } from './LinkButton'
|
||||
export { useCloseDialog } from './useCloseDialog'
|
||||
export { Dialog, type DialogProps } from './Dialog'
|
||||
export { Div } from './Div'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user