Compare commits
208 Commits
v0.1.3
...
add-recordings
| 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 | |||
| f76e7f0e51 | |||
| b9ffbd179c | |||
| a3ef1d4a26 | |||
| d11bcc5de9 | |||
| 412914cf01 | |||
| d511aedd39 | |||
| 7edf7d194b | |||
| 6324608a4a | |||
| a402c2f46f | |||
| e21858febe | |||
| 3d615fa582 | |||
| 4293444d3e | |||
| a55a4d5b5d | |||
| dd9a87a33b | |||
| 24242ef01a | |||
| b537cdfd93 | |||
| 80cc7c723f | |||
| e9210213b1 | |||
| 634f1924be | |||
| 4e175a8361 | |||
| c5ce32ef79 | |||
| aaf6b03a25 | |||
| 66bc739411 | |||
| 1b48fa256e | |||
| 053c4a40e9 | |||
| 53d732d802 | |||
| 26ca81db40 | |||
| e6e6a3bde7 | |||
| 26fdaac589 | |||
| 1fd1cb71ba | |||
| c8023573c6 | |||
| 584be7e65b | |||
| 59ec88e84a | |||
| 1b2e0ad431 | |||
| 20464a2845 | |||
| ece6284de2 | |||
| 82e994e5b1 | |||
| 85aa7a7251 | |||
| c4ececd03a | |||
| 57bba04cf3 | |||
| 11e162dbd4 | |||
| e06e9d1496 | |||
| c8cc9909ba | |||
| c218a1f7a2 | |||
| 490aaba30a | |||
| 790d37569c | |||
| 1e140a01b5 | |||
| 4ecb7202ec | |||
| 4b7419fe4a | |||
| 44d3c738d7 | |||
| 888c66a218 | |||
| 86641bd160 | |||
| 3d91af23cc | |||
| 2d0d30ce01 | |||
| 98ae9af84a | |||
| 3dda12ada6 | |||
| 8b2750c413 | |||
| c16c27fa50 | |||
| b3ef57e1b6 | |||
| b554a6a542 | |||
| 574fd6dc89 | |||
| 8d77332a0a | |||
| 0e2c805b41 | |||
| 9782eb8c7c | |||
| f6bc57ba91 | |||
| 4d5aec9a49 | |||
| 74b296aa37 | |||
| 88fadd1d61 | |||
| cac58f49d3 | |||
| 12c27eedac | |||
| 6a7ec95493 | |||
| e41656a760 | |||
| eb90c0f28c | |||
| f3c4b0ac40 | |||
| 038e6368e4 | |||
| 028f20375f | |||
| ccc23ff46a | |||
| e3b7a1f77b | |||
| 69a8eea1ce | |||
| 20493edd07 | |||
| 79f7fcab6e | |||
| ce3a6dab12 | |||
| 1b57dd0ecf | |||
| c6318910c2 | |||
| 03b3630611 | |||
| 5b8c8d493a | |||
| b12fb6bf48 | |||
| a8f64f5a36 | |||
| 6ab5b3300a | |||
| 6189e6454d | |||
| a8b2c56f4b | |||
| b472220151 | |||
| 223cee3828 | |||
| 101be503bf | |||
| 47e2d85471 | |||
| 16321b3cb0 | |||
| 144cb56cda | |||
| e6a15e36b4 | |||
| 3541af5992 | |||
| 68f0ea3639 | |||
| 925eb92c60 | |||
| d965ee7060 | |||
| aef85bb1ab | |||
| 8f59b08088 | |||
| 366998b3d6 | |||
| c8ad0ab24f | |||
| d2bbcb0f51 | |||
| 40457bd68a | |||
| c21c5f604b | |||
| 66350b8fb5 | |||
| 378cc3a651 | |||
| 28f43fb2c0 | |||
| 37eea16a50 | |||
| fe8ed43aae | |||
| 671aa68804 | |||
| 6449aaf7ea | |||
| f8fff3dbdf | |||
| 1715ec10dd | |||
| 8c7aed4b00 | |||
| 217b19e42a | |||
| bd26a0abc1 | |||
| abb708aa49 | |||
| 5d35161ae3 | |||
| 0b6f58bf9c | |||
| 79519fef26 | |||
| d7b1fbaf28 | |||
| 26bc67d1b4 | |||
| b783a8bac6 |
@@ -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
|
||||
|
||||
@@ -160,6 +163,21 @@ jobs:
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && npm run lint
|
||||
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
|
||||
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
@@ -259,19 +259,19 @@ i18n-generate-and-upload: \
|
||||
# -- Mail generator
|
||||
|
||||
mails-build: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build
|
||||
@$(MAIL_NPM) run build
|
||||
.PHONY: mails-build
|
||||
|
||||
mails-build-html-to-plain-text: ## Convert html files to text
|
||||
@$(MAIL_YARN) build-html-to-plain-text
|
||||
@$(MAIL_NPM) run build-html-to-plain-text
|
||||
.PHONY: mails-build-html-to-plain-text
|
||||
|
||||
mails-build-mjml-to-html: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build-mjml-to-html
|
||||
@$(MAIL_NPM) run build-mjml-to-html
|
||||
.PHONY: mails-build-mjml-to-html
|
||||
|
||||
mails-install: ## install the mail generator
|
||||
@$(MAIL_YARN) install
|
||||
@$(MAIL_NPM) install
|
||||
.PHONY: mails-install
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
@@ -92,66 +91,3 @@ function _dc_exec() {
|
||||
function _django_manage() {
|
||||
_dc_run "app-dev" python manage.py "$@"
|
||||
}
|
||||
|
||||
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
|
||||
# terraform directory.
|
||||
#
|
||||
# usage: _set_openstack_project
|
||||
#
|
||||
# If necessary the script will prompt the user to choose a project from those available
|
||||
function _set_openstack_project() {
|
||||
|
||||
declare prompt
|
||||
declare -a projects
|
||||
declare -i default=1
|
||||
declare -i choice=0
|
||||
declare -i n_projects
|
||||
|
||||
# List projects by looking in the "./env.d/terraform" directory
|
||||
# and store them in an array
|
||||
read -r -a projects <<< "$(
|
||||
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
|
||||
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
|
||||
xargs
|
||||
)"
|
||||
nb_projects=${#projects[@]}
|
||||
|
||||
if [[ ${nb_projects} -le 0 ]]; then
|
||||
echo "There are no OpenStack projects defined..." >&2
|
||||
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
|
||||
"of your project and copy your \"openrc.sh\" file into it." >&2
|
||||
exit 10
|
||||
fi
|
||||
|
||||
if [[ ${nb_projects} -gt 1 ]]; then
|
||||
prompt="Select an OpenStack project to target:\\n"
|
||||
for (( i=0; i<nb_projects; i++ )); do
|
||||
prompt+="[$((i+1))] ${projects[$i]}"
|
||||
if [[ $((i+1)) -eq ${default} ]]; then
|
||||
prompt+=" (default)"
|
||||
fi
|
||||
prompt+="\\n"
|
||||
done
|
||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
||||
prompt+="Your choice: "
|
||||
read -r -p "$(echo -e "${prompt}")" choice
|
||||
|
||||
if [[ ${choice} -gt nb_projects ]]; then
|
||||
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
|
||||
exit 11
|
||||
fi
|
||||
|
||||
if [[ ${choice} -le 0 ]]; then
|
||||
choice=${default}
|
||||
fi
|
||||
fi
|
||||
|
||||
project=${projects[$((choice-1))]}
|
||||
# Check that the openrc.sh file exists for this project
|
||||
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
|
||||
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
|
||||
exit 12
|
||||
fi
|
||||
|
||||
echo "${project}"
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
terraform-state "$@"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
-e TF_VAR_user_name \
|
||||
terraform "$@"
|
||||
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:
|
||||
|
||||
+19
-11
@@ -3,20 +3,28 @@
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
```
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
|
||||
...
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
```
|
||||
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
|
||||
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
|
||||
@@ -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,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
+1
-1
Submodule secrets updated: 9da011f5c2...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,13 +126,13 @@ 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.GET.get("username", None)
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
output["livekit"] = {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
@@ -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,18 @@
|
||||
# Generated by Django 5.0.7 on 2024-08-07 14:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_create_pg_trgm_extension'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='room',
|
||||
name='configuration',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Values for Visio parameters to configure the room.', verbose_name='Visio room configuration'),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_alter_room_configuration'),
|
||||
]
|
||||
|
||||
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'),
|
||||
),
|
||||
]
|
||||
@@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
+83
-27
@@ -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):
|
||||
@@ -296,7 +308,7 @@ class Room(Resource):
|
||||
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default={},
|
||||
default=dict,
|
||||
verbose_name=_("Visio room configuration"),
|
||||
help_text=_("Values for Visio parameters to configure the room."),
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
+72
-10
@@ -2,16 +2,45 @@
|
||||
Utils functions used in the core app
|
||||
"""
|
||||
|
||||
# ruff: noqa:S311
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
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
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
"""Generates a consistent HSL color based on a given identity string.
|
||||
|
||||
The function seeds the random generator with the identity's hash,
|
||||
ensuring consistent color output. The HSL format allows fine-tuned control
|
||||
over saturation and lightness, empirically adjusted to produce visually
|
||||
appealing and distinct colors. HSL is preferred over hex to constrain the color
|
||||
range and ensure predictability.
|
||||
"""
|
||||
|
||||
# ruff: noqa:S324
|
||||
identity_hash = hashlib.sha1(identity.encode("utf-8"))
|
||||
# Keep only hash's last 16 bits, collisions are not a concern
|
||||
seed = int(identity_hash.hexdigest(), 16) & 0xFFFF
|
||||
random.seed(seed)
|
||||
hue = random.randint(0, 360)
|
||||
saturation = random.randint(50, 75)
|
||||
lightness = random.randint(25, 60)
|
||||
|
||||
return f"hsl({hue}, {saturation}%, {lightness}%)"
|
||||
|
||||
|
||||
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
"""Generate a Livekit access token for a user in a specific room.
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
Args:
|
||||
room (str): The name of the room.
|
||||
@@ -22,10 +51,11 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
video_grants = VideoGrants(
|
||||
room=room,
|
||||
room_join=True,
|
||||
room_admin=True,
|
||||
can_update_own_metadata=True,
|
||||
can_publish_sources=[
|
||||
"camera",
|
||||
"microphone",
|
||||
@@ -34,18 +64,50 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
],
|
||||
)
|
||||
|
||||
token = AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
).with_grants(video_grants)
|
||||
|
||||
if user.is_anonymous:
|
||||
token.with_identity(str(uuid4()))
|
||||
identity = str(uuid4())
|
||||
default_username = "Anonymous"
|
||||
else:
|
||||
token.with_identity(user.sub)
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
|
||||
token.with_name(username or default_username)
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
)
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_metadata(json.dumps({"color": generate_color(identity)}))
|
||||
)
|
||||
|
||||
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.
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
|
||||
import json
|
||||
import os
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -71,6 +72,7 @@ class Base(Configuration):
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
SILENCED_SYSTEM_CHECKS = values.ListValue([])
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "meet.urls"
|
||||
@@ -117,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/
|
||||
|
||||
@@ -249,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)
|
||||
@@ -256,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"
|
||||
@@ -273,6 +308,7 @@ class Base(Configuration):
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
||||
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
||||
THUMBNAIL_ALIASES = {}
|
||||
|
||||
# Celery
|
||||
@@ -513,7 +549,11 @@ class Production(Base):
|
||||
"""
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue(None)
|
||||
ALLOWED_HOSTS = [
|
||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||
gethostbyname(gethostname()),
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
@@ -528,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:
|
||||
|
||||
+18
-18
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.3"
|
||||
version = "0.1.7"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,39 +25,38 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.34.153",
|
||||
"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.0.7",
|
||||
"django==5.1.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.9",
|
||||
"factory_boy==3.3.0",
|
||||
"freezegun==1.5.1",
|
||||
"gunicorn==22.0.0",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.6",
|
||||
"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.12.0",
|
||||
"sentry-sdk==2.15.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.6.0",
|
||||
"livekit-api==0.7.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -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.26.0",
|
||||
"ipython==8.27.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.6",
|
||||
"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.5.6",
|
||||
"types-requests==2.32.0.20240712",
|
||||
"ruff==0.6.5",
|
||||
"types-requests==2.32.0.20240914",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -7,6 +7,7 @@ module.exports = {
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'prettier'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
||||
@@ -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
+2632
-2828
File diff suppressed because it is too large
Load Diff
+38
-29
@@ -1,51 +1,60 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
"build": "panda codegen && tsc -b && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json"
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||
"format": "prettier --write ./src",
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.3.3",
|
||||
"@livekit/components-styles": "1.0.12",
|
||||
"@pandacss/preset-panda": "0.41.0",
|
||||
"@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",
|
||||
"valtio": "1.13.2",
|
||||
"wouter": "3.3.0"
|
||||
"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": "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-plugin-jsx-a11y": "6.9.0",
|
||||
"eslint-config-prettier": "9.1.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",
|
||||
"typescript": "5.5.2",
|
||||
"vite": "5.3.1",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
"eslint-plugin-react-refresh": "0.4.12",
|
||||
"postcss": "8.4.47",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.8",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,34 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||
pulse: {
|
||||
'0%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0.7)' },
|
||||
'75%': { boxShadow: '0 0 0 30px rgba(255, 255, 255, 0)' },
|
||||
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
|
||||
},
|
||||
active_speaker: {
|
||||
'0%': { height: '25%' },
|
||||
'25%': { height: '45%' },
|
||||
'50%': { height: '20%' },
|
||||
'100%': { height: '55%' },
|
||||
},
|
||||
active_speaker_small: {
|
||||
'0%': { height: '20%' },
|
||||
'25%': { height: '25%' },
|
||||
'50%': { height: '18%' },
|
||||
'100%': { height: '25%' },
|
||||
},
|
||||
wave_hand: {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'20%': { transform: 'rotate(-20deg)' },
|
||||
'80%': { transform: 'rotate(20deg)' },
|
||||
'100%': { transform: 'rotate(0)' },
|
||||
},
|
||||
pulse_mic: {
|
||||
'0%': { color: 'primary', opacity: '1' },
|
||||
'50%': { color: 'primary', opacity: '0.8' },
|
||||
'100%': { color: 'primary', opacity: '1' },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
@@ -199,6 +227,7 @@ const config: Config = {
|
||||
text: { value: '{colors.white}' },
|
||||
subtle: { value: '{colors.red.100}' },
|
||||
'subtle-text': { value: '{colors.red.700}' },
|
||||
...pandaPreset.theme.tokens.colors.red,
|
||||
},
|
||||
success: {
|
||||
DEFAULT: { value: '{colors.green.700}' },
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+15
-16
@@ -6,33 +6,32 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
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 { 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}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import React from 'react'
|
||||
|
||||
const avatar = cva({
|
||||
base: {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.25rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
context: 'list',
|
||||
},
|
||||
})
|
||||
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
name?: string
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = ({ name, bgColor, context, ...props }: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) || ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
className={avatar({ context })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Center } from '@/styled-system/jsx'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
export const ErrorScreen = ({ title, body }: {
|
||||
export const ErrorScreen = ({
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
title?: string
|
||||
body?: string
|
||||
}) => {
|
||||
|
||||
@@ -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,52 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
|
||||
export const SoundTester = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
|
||||
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
|
||||
useEffect(() => {
|
||||
const updateActiveId = async (deviceId: string) => {
|
||||
try {
|
||||
await audioRef?.current?.setSinkId(deviceId)
|
||||
} catch (error) {
|
||||
console.error(`Error setting sinkId: ${error}`)
|
||||
}
|
||||
}
|
||||
updateActiveId(activeDeviceId)
|
||||
}, [activeDeviceId])
|
||||
|
||||
// prevent pausing the sound
|
||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
invisible
|
||||
onPress={() => {
|
||||
audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
}}
|
||||
size="sm"
|
||||
isDisabled={isPlaying}
|
||||
fullWidth
|
||||
style={{
|
||||
color: isPlaying ? 'var(--colors-primary)' : undefined,
|
||||
}}
|
||||
>
|
||||
{isPlaying ? t('audio.speakers.ongoingTest') : t('audio.speakers.test')}
|
||||
</Button>
|
||||
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="sounds/uprise.mp3"
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,5 +4,7 @@ export const authUrl = ({
|
||||
silent = false,
|
||||
returnTo = window.location.href,
|
||||
} = {}) => {
|
||||
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
|
||||
return apiUrl(
|
||||
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { authUrl } from "@/features/auth";
|
||||
import { authUrl } from '@/features/auth'
|
||||
|
||||
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
|
||||
|
||||
const isRetryAllowed = () => {
|
||||
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
|
||||
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY)
|
||||
if (!lastRetryDate) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
return now.getTime() > Number(lastRetryDate)
|
||||
}
|
||||
|
||||
const setNextRetryTime = (retryIntervalInSeconds: number) => {
|
||||
const now = new Date()
|
||||
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
|
||||
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
|
||||
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000
|
||||
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime))
|
||||
}
|
||||
|
||||
const initiateSilentLogin = () => {
|
||||
|
||||
@@ -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()) ? (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
|
||||
|
||||
// fixme - duplication with the InviteDialog
|
||||
export const LaterMeetingDialog = ({
|
||||
roomId,
|
||||
...dialogProps
|
||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={!!roomId}
|
||||
{...dialogProps}
|
||||
title={t('laterMeetingDialog.heading')}
|
||||
>
|
||||
<P>{t('laterMeetingDialog.description')}</P>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('laterMeetingDialog.copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="later-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('laterMeetingDialog.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('laterMeetingDialog.copy')
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<HStack>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#d9e5ff',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
>
|
||||
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('laterMeetingDialog.permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger } from 'react-aria-components'
|
||||
import { Button, Text } from '@/primitives'
|
||||
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 { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom({
|
||||
onSuccess: (data) => {
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
}
|
||||
});
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
@@ -37,30 +42,64 @@ export const Home = () => {
|
||||
{t('loginToCreateMeeting')}
|
||||
</Text>
|
||||
)}
|
||||
<HStack gap="gutter">
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={
|
||||
isLoggedIn
|
||||
? async () => {
|
||||
const slug = generateRoomId()
|
||||
await createRoom({slug})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
href={isLoggedIn ? undefined : authUrl()}
|
||||
>
|
||||
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
|
||||
</Button>
|
||||
|
||||
<HStack gap="gutter" alignItems="start">
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoomId(data.slug)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<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 />
|
||||
</DialogTrigger>
|
||||
</HStack>
|
||||
</Centered>
|
||||
<LaterMeetingDialog
|
||||
roomId={laterRoomId || ''}
|
||||
onOpenChange={() => setLaterRoomId(null)}
|
||||
/>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { Div } from '@/primitives'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
useEffect(() => {
|
||||
const showJoinNotification = (participant: Participant) => {
|
||||
if (isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
triggerNotificationSound(NotificationType.Joined)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.Joined,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
}
|
||||
room.on(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
useEffect(() => {
|
||||
const removeJoinNotification = (participant: Participant) => {
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
toast.content.participant === participant &&
|
||||
toast.content.type === NotificationType.Joined
|
||||
)
|
||||
if (existingToast) {
|
||||
toastQueue.close(existingToast.key)
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationReceived = (
|
||||
prevMetadataStr: string | undefined,
|
||||
participant: Participant
|
||||
) => {
|
||||
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 && prevMetadata.raised && !metadata.raised) {
|
||||
toastQueue.close(existingToast.key)
|
||||
return
|
||||
}
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
triggerNotificationSound(NotificationType.Raised)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.Raised,
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
|
||||
return () => {
|
||||
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 />
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum NotificationType {
|
||||
Joined = 'joined',
|
||||
Default = 'default',
|
||||
Raised = 'raised',
|
||||
Lowered = 'lowered',
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ToastState } from '@react-stately/toast'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRef } from 'react'
|
||||
import { ToastData } from './ToastProvider'
|
||||
import type { QueuedToast } from '@react-stately/toast'
|
||||
|
||||
export const StyledToastContainer = styled('div', {
|
||||
base: {
|
||||
margin: 0.5,
|
||||
boxShadow:
|
||||
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
|
||||
backgroundColor: '#494c4f',
|
||||
color: 'white',
|
||||
borderRadius: '8px',
|
||||
'&[data-entering]': { animation: 'fade 200ms' },
|
||||
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
||||
width: 'fit-content',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledToast = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '10px',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ToastProps {
|
||||
key: string
|
||||
toast: QueuedToast<ToastData>
|
||||
state: ToastState<ToastData>
|
||||
}
|
||||
|
||||
export function Toast({ state, ...props }: ToastProps) {
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps, closeButtonProps } = useToast(
|
||||
props,
|
||||
state,
|
||||
ref
|
||||
)
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<StyledToast>
|
||||
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||
<Button square size="sm" invisible {...closeButtonProps}>
|
||||
<RiCloseLine color="white" />
|
||||
</Button>
|
||||
</StyledToast>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
borderRadius: 'inherit',
|
||||
},
|
||||
})
|
||||
|
||||
export function ToastJoined({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||
props,
|
||||
state,
|
||||
ref
|
||||
)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const participant = props.toast.content.participant
|
||||
const trackReference = {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const pinParticipant = () => {
|
||||
layoutContext?.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<ClickableToast
|
||||
ref={ref}
|
||||
onPress={(e) => {
|
||||
pinParticipant()
|
||||
closeButtonProps.onPress?.(e)
|
||||
}}
|
||||
>
|
||||
<HStack justify="center" alignItems="center" {...contentProps}>
|
||||
<Div display="flex" overflow="hidden" width="128" height="72">
|
||||
<ParticipantTile
|
||||
trackRef={trackReference}
|
||||
disableSpeakingIndicator={true}
|
||||
disableMetadata={true}
|
||||
style={{
|
||||
borderRadius: '7px 0 0 7px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</Div>
|
||||
<Div padding={20} {...titleProps}>
|
||||
{t('joined.description', {
|
||||
name: participant.name || t('defaultName'),
|
||||
})}
|
||||
</Div>
|
||||
</HStack>
|
||||
</ClickableToast>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
||||
import { ToastRegion } from './ToastRegion'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
export interface ToastData {
|
||||
participant: Participant
|
||||
type: NotificationType
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Using a global queue for toasts allows for centralized management and queuing of notifications
|
||||
// from anywhere in the app, providing greater flexibility in complex scenarios.
|
||||
export const toastQueue = new ToastQueue<ToastData>({
|
||||
maxVisibleToasts: 5,
|
||||
})
|
||||
|
||||
export const ToastProvider = ({ ...props }) => {
|
||||
const state = useToastQueue<ToastData>(toastQueue)
|
||||
return (
|
||||
<>
|
||||
{state.visibleToasts.length > 0 && (
|
||||
<ToastRegion {...props} state={state} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine, RiHand } from '@remixicon/react'
|
||||
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
|
||||
|
||||
export function ToastRaised({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||
props,
|
||||
state,
|
||||
ref
|
||||
)
|
||||
const participant = props.toast.content.participant
|
||||
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
<RiHand
|
||||
color="white"
|
||||
style={{
|
||||
marginRight: '1rem',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
<Div {...titleProps} marginRight={0.5}>
|
||||
{t('raised.description', {
|
||||
name: participant.name || t('defaultName'),
|
||||
})}
|
||||
</Div>
|
||||
{!isParticipantsOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
closeButtonProps.onPress?.(e)
|
||||
}}
|
||||
>
|
||||
{t('raised.cta')}
|
||||
</Button>
|
||||
)}
|
||||
<Button square size="sm" invisible {...closeButtonProps}>
|
||||
<RiCloseLine size={18} color="white" />
|
||||
</Button>
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
||||
import type { ToastState } from '@react-stately/toast'
|
||||
import { Toast } from './Toast'
|
||||
import { useRef } from 'react'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
import { ToastJoined } from './ToastJoined'
|
||||
import { ToastData } from './ToastProvider'
|
||||
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
}
|
||||
|
||||
export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
const ref = useRef(null)
|
||||
const { regionProps } = useToastRegion(props, state, ref)
|
||||
return (
|
||||
<div {...regionProps} ref={ref} className="toast-region">
|
||||
{state.visibleToasts.map((toast) => {
|
||||
if (toast.content?.type === NotificationType.Joined) {
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.Raised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import useSound from 'use-sound'
|
||||
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
const [play] = useSound('./sounds/notifications.mp3', {
|
||||
sprite: {
|
||||
joined: [0, 1150],
|
||||
raised: [1400, 180],
|
||||
message: [1580, 300],
|
||||
waiting: [2039, 710],
|
||||
success: [2740, 1304],
|
||||
},
|
||||
})
|
||||
const triggerNotificationSound = (type: string) => {
|
||||
play({ id: type })
|
||||
}
|
||||
return { triggerNotificationSound }
|
||||
}
|
||||
@@ -9,6 +9,6 @@ export type ApiRoom = {
|
||||
token: string
|
||||
}
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean;
|
||||
[key: string]: string | number | boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
|
||||
import { fetchApi } from '@/api/fetchApi';
|
||||
import { ApiError } from "@/api/ApiError";
|
||||
import { ApiRoom } from "./ApiRoom";
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string;
|
||||
slug: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/`, {
|
||||
const createRoom = ({
|
||||
slug,
|
||||
username = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
}),
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
|
||||
export function useCreateRoom(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
|
||||
mutationFn: createRoom,
|
||||
onSuccess: options?.onSuccess,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
boxSizing: 'border-box',
|
||||
backgroundColor: 'primary',
|
||||
borderRadius: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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', {
|
||||
base: {
|
||||
backgroundColor: 'white',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '4px',
|
||||
animationDuration: '400ms',
|
||||
animationName: 'active_speaker',
|
||||
animationIterationCount: 'infinite',
|
||||
animationDirection: 'alternate',
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
true: {
|
||||
animationIterationCount: 'infinite',
|
||||
},
|
||||
false: {
|
||||
animationIterationCount: 0,
|
||||
},
|
||||
},
|
||||
size: {
|
||||
small: {
|
||||
animationName: 'active_speaker_small',
|
||||
},
|
||||
},
|
||||
pushToTalk: {
|
||||
true: {
|
||||
backgroundColor: 'primary',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type ActiveSpeakerProps = {
|
||||
isSpeaking: boolean
|
||||
pushToTalk?: boolean
|
||||
}
|
||||
|
||||
export const ActiveSpeaker = ({
|
||||
isSpeaking,
|
||||
pushToTalk,
|
||||
}: ActiveSpeakerProps) => {
|
||||
return (
|
||||
<StyledContainer pushToTalk={pushToTalk}>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
animationDelay: '300ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
animationDelay: '500ms',
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
LiveKitRoom,
|
||||
VideoConference,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
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 +17,9 @@ import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
userConfig,
|
||||
@@ -30,17 +31,28 @@ export const Conference = ({
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||
|
||||
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
status: createStatus,
|
||||
isError: isCreateError,
|
||||
} = useCreateRoom({
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(fetchKey, data)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
|
||||
const {
|
||||
status: fetchStatus,
|
||||
isError: isFetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
queryKey: fetchKey,
|
||||
enabled: !initialRoomData,
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
initialData: initialRoomData,
|
||||
queryFn: () =>
|
||||
fetchRoom({
|
||||
@@ -48,7 +60,7 @@ export const Conference = ({
|
||||
username: userConfig.username,
|
||||
}).catch((error) => {
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({slug: roomId})
|
||||
createRoom({ slug: roomId, username: userConfig.username })
|
||||
}
|
||||
}),
|
||||
retry: false,
|
||||
@@ -70,34 +82,20 @@ 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.
|
||||
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('error.createRoom.heading')}
|
||||
body={t('error.createRoom.body')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||
<Screen>
|
||||
<Screen header={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={data?.livekit?.url}
|
||||
@@ -106,9 +104,7 @@ export const Conference = ({
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
>
|
||||
<VideoConference
|
||||
chatMessageFormatter={formatChatMessageLinks}
|
||||
/>
|
||||
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
|
||||
{showInviteDialog && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { Div, Button, Dialog, Input, type DialogProps } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Div, Button, type DialogProps, P } from '@/primitives'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiFileCopyLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// fixme - extract in a proper primitive this dialog without overlay
|
||||
const StyledRACDialog = styled(Dialog, {
|
||||
base: {
|
||||
position: 'fixed',
|
||||
left: '0.75rem',
|
||||
bottom: 80,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
width: '24.5rem',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
boxShadow:
|
||||
'0 1px 2px 0 rgba(60, 64, 67, .3), 0 2px 6px 2px rgba(60, 64, 67, .15)',
|
||||
backgroundColor: 'white',
|
||||
'&[data-entering]': { animation: 'fade 200ms' },
|
||||
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
||||
},
|
||||
})
|
||||
|
||||
export const InviteDialog = ({
|
||||
roomId,
|
||||
@@ -11,48 +40,103 @@ export const InviteDialog = ({
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
|
||||
const copyLabel = t('shareDialog.copy')
|
||||
const copiedLabel = t('shareDialog.copied')
|
||||
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (copyLinkLabel == copiedLabel) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCopyLinkLabel(copyLabel)
|
||||
}, 5000)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [copyLinkLabel, copyLabel, copiedLabel])
|
||||
}, [isCopied])
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog {...dialogProps} title={t('shareDialog.heading')}>
|
||||
<HStack alignItems="stretch" gap="gutter">
|
||||
<Div flex="1">
|
||||
<Input
|
||||
type="text"
|
||||
aria-label={t('shareDialog.inputLabel')}
|
||||
value={roomUrl}
|
||||
readOnly
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select()
|
||||
}}
|
||||
/>
|
||||
</Div>
|
||||
<Div minWidth="8rem">
|
||||
<StyledRACDialog {...dialogProps}>
|
||||
{({ close }) => (
|
||||
<VStack
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
gap={0}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||
{t('shareDialog.heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
dialogProps.onClose?.()
|
||||
close()
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<P>{t('shareDialog.description')}</P>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant={isCopied ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('shareDialog.copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setCopyLinkLabel(copiedLabel)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{copyLinkLabel}
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('shareDialog.copy')
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Div>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
<HStack>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#d9e5ff',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
>
|
||||
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('shareDialog.permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</StyledRACDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const buildServerApiUrl = (origin: string, path: string) => {
|
||||
const sanitizedOrigin = origin.replace(/\/$/, '')
|
||||
const sanitizedPath = path.replace(/^\//, '')
|
||||
return `${sanitizedOrigin}/${sanitizedPath}`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export const fetchServerApi = async <T = Record<string, unknown>>(
|
||||
url: string,
|
||||
token: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> => {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Participant } from 'livekit-client'
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
|
||||
export const useLowerHandParticipant = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const lowerHandParticipant = (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
const newMetadata = JSON.parse(participant.metadata || '{}')
|
||||
newMetadata.raised = !newMetadata.raised
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'twirp/livekit.RoomService/UpdateParticipant'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
metadata: JSON.stringify(newMetadata),
|
||||
permission: participant.permissions,
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
return { lowerHandParticipant }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant'
|
||||
|
||||
export const useLowerHandParticipants = () => {
|
||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||
|
||||
const lowerHandParticipants = (participants: Array<Participant>) => {
|
||||
try {
|
||||
const promises = participants.map((participant) =>
|
||||
lowerHandParticipant(participant)
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while lowering hands :', error)
|
||||
throw new Error('An error occurred while lowering hands.')
|
||||
}
|
||||
}
|
||||
return { lowerHandParticipants }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const muteParticipant = (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
const trackSid = participant.getTrackPublication(
|
||||
Source.Microphone
|
||||
)?.trackSid
|
||||
|
||||
if (!trackSid) {
|
||||
throw new Error('Missing audio track')
|
||||
}
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'twirp/livekit.RoomService/MutePublishedTrack'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
muted: true,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
return { muteParticipant }
|
||||
}
|
||||
@@ -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,6 @@
|
||||
import { ParticipantTile } from './ParticipantTile'
|
||||
import { FocusLayoutProps } from '@livekit/components-react'
|
||||
|
||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||
return <ParticipantTile trackRef={trackRef} {...htmlProps} />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
import { Div } from '@/primitives'
|
||||
import { RiMicOffFill } from '@remixicon/react'
|
||||
|
||||
export const MutedMicIndicator = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Source.Microphone,
|
||||
})
|
||||
|
||||
if (!isMuted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Div padding={0.25} backgroundColor="red" borderRadius="4px">
|
||||
<RiMicOffFill size={16} color="white" />
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Participant } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useIsSpeaking } from '@livekit/components-react'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#3d4043', // fixme - copied from gmeet
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantPlaceholderProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const ParticipantPlaceholder = ({
|
||||
participant,
|
||||
}: ParticipantPlaceholderProps) => {
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const participantColor = getParticipantColor(participant)
|
||||
|
||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||
const { width, height } = useSize(placeholderEl)
|
||||
|
||||
const minDimension = Math.min(width, height)
|
||||
const avatarSize = useMemo(
|
||||
() => Math.min(Math.round(minDimension * 0.9), 160),
|
||||
[minDimension]
|
||||
)
|
||||
|
||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||
|
||||
return (
|
||||
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
|
||||
height: 'auto',
|
||||
aspectRatio: '1/1',
|
||||
width: '80%',
|
||||
maxWidth: `${avatarSize}px`,
|
||||
fontSize: `${initialSize}px`,
|
||||
}}
|
||||
>
|
||||
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
|
||||
<Avatar
|
||||
name={participant.name}
|
||||
bgColor={participantColor}
|
||||
context="placeholder"
|
||||
/>
|
||||
</div>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
FocusToggle,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
VideoTrack,
|
||||
TrackRefContext,
|
||||
ParticipantContextIfNeeded,
|
||||
} from '@livekit/components-react'
|
||||
import React from 'react'
|
||||
import {
|
||||
isTrackReference,
|
||||
isTrackReferencePinned,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
trackRef?: TrackReferenceOrPlaceholder
|
||||
}>
|
||||
) {
|
||||
const hasContext = !!useMaybeTrackRefContext()
|
||||
return props.trackRef && !hasContext ? (
|
||||
<TrackRefContext.Provider value={props.trackRef}>
|
||||
{props.children}
|
||||
</TrackRefContext.Provider>
|
||||
) : (
|
||||
<>{props.children}</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||
disableMetadata?: boolean
|
||||
}
|
||||
|
||||
export const ParticipantTile: (
|
||||
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ParticipantTileExtendedProps
|
||||
>(function ParticipantTile(
|
||||
{
|
||||
trackRef,
|
||||
children,
|
||||
onParticipantClick,
|
||||
disableSpeakingIndicator,
|
||||
disableMetadata,
|
||||
...htmlProps
|
||||
}: ParticipantTileExtendedProps,
|
||||
ref
|
||||
) {
|
||||
const trackReference = useEnsureTrackRef(trackRef)
|
||||
|
||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||
htmlProps,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
|
||||
const handleSubscribe = React.useCallback(
|
||||
(subscribed: boolean) => {
|
||||
if (
|
||||
trackReference.source &&
|
||||
!subscribed &&
|
||||
layoutContext &&
|
||||
layoutContext.pin.dispatch &&
|
||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||
) {
|
||||
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
||||
}
|
||||
},
|
||||
[trackReference, layoutContext]
|
||||
)
|
||||
|
||||
const { isHandRaised } = useRaisedHand({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
(trackReference.publication?.kind === 'video' ||
|
||||
trackReference.source === Track.Source.Camera ||
|
||||
trackReference.source === Track.Source.ScreenShare) ? (
|
||||
<VideoTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
manageSubscription={autoManageSubscription}
|
||||
/>
|
||||
) : (
|
||||
isTrackReference(trackReference) && (
|
||||
<AudioTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
minHeight: '24px',
|
||||
backgroundColor: isHandRaised ? 'white' : undefined,
|
||||
color: isHandRaised ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{trackReference.source === Track.Source.Camera ? (
|
||||
<>
|
||||
{isHandRaised && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<LockLockedIcon
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<ParticipantName />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||
<ParticipantName>'s screen</ParticipantName>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiChat1Line } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
|
||||
|
||||
export const ChatToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
||||
|
||||
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isChatOpen}
|
||||
onPress={() => toggleChat()}
|
||||
data-attr={`controls-chat-${tooltipLabel}`}
|
||||
>
|
||||
<RiChat1Line />
|
||||
</ToggleButton>
|
||||
{!!unreadMessages && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-.25rem',
|
||||
right: '-.25rem',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
backgroundColor: 'red',
|
||||
borderRadius: '50%',
|
||||
zIndex: 1,
|
||||
border: '2px solid #d1d5db',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
|
||||
export const HandToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
|
||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={() => toggleRaisedHand()}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMore2Line } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
|
||||
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
||||
|
||||
export type DialogState = 'username' | 'settings' | null
|
||||
|
||||
export const OptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<Button
|
||||
square
|
||||
legacyStyle
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
<OptionsMenuItems onOpenDialog={setDialogOpen} />
|
||||
</Menu>
|
||||
<SettingsDialogExtended
|
||||
isOpen={dialogOpen === 'settings'}
|
||||
onOpenChange={(v) => !v && setDialogOpen(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user