Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24509c96d1 | |||
| 88eddd85d7 | |||
| f3eeeff96f | |||
| 1e339023b3 |
@@ -11,17 +11,34 @@ jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
|
||||
@@ -19,9 +19,26 @@ jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -31,7 +48,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -52,9 +69,26 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -64,7 +98,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -86,9 +120,26 @@ jobs:
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -98,7 +149,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -111,20 +162,39 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
|
||||
@@ -175,56 +175,25 @@ jobs:
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
lint-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Check format
|
||||
run: npm run check
|
||||
|
||||
build-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-sdk
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build SDK
|
||||
run: npm run build
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "infrastructure,secrets"
|
||||
- name: Checkout repository
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
- name: Load sops secrets
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
|
||||
@@ -3,8 +3,10 @@ run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- src/helm/meet/**
|
||||
- ./src/helm/meet/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.13"
|
||||
version = "0.1.12"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.36.6",
|
||||
"boto3==1.36.2",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
@@ -83,7 +83,7 @@ dev = [
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.6",
|
||||
"ruff==0.9.3",
|
||||
"ruff==0.9.2",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
|
||||
@@ -34,11 +34,6 @@ RUN npm run build
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-react": "2.8.0",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
@@ -17,25 +17,25 @@
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.2",
|
||||
"i18next": "24.2.1",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.210.2",
|
||||
"posthog-js": "1.207.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.1.3",
|
||||
"wouter": "3.4.0"
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.11",
|
||||
"vite": "6.0.10",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -436,7 +436,6 @@
|
||||
},
|
||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1241,9 +1240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-react": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.1.tgz",
|
||||
"integrity": "sha512-XpuDu7iDMcN4pkV8CYNzHf9hLNdYOeEtbmCr7Zesy6Au3BxUl4aS1Ajmg0b75Rx7zTlkyCJt9Lm4VrEqbJCI6Q==",
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.0.tgz",
|
||||
"integrity": "sha512-Zgt2zv+j2gWJad0mcTDR8cS6KQNXYfAeOL9Si5Lb2Q2k3iPUJinPDMqubM6btnduhkPjy+EF4kA8bNAY0xu4Ig==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-core": "0.12.1",
|
||||
@@ -3880,9 +3879,9 @@
|
||||
"integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
|
||||
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
|
||||
"version": "22.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
|
||||
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6396,9 +6395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "24.2.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz",
|
||||
"integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==",
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz",
|
||||
"integrity": "sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -8445,9 +8444,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.210.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.210.2.tgz",
|
||||
"integrity": "sha512-rIbn/h9ur7uA0PS4dClOr9w6txLfHS94yh9yafA5VM2eXToM951XtMYtIQ6bi6wFzpvpFvTQFeYLQ/9/xZ59AQ==",
|
||||
"version": "1.207.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.207.0.tgz",
|
||||
"integrity": "sha512-Sx+xamhg1/iKGAtUNh3uAUtAAza4j/yBhxcfUxfqR++WrZdw0V6nmh7LSfVNl7+QVl2qmiPSoZA7z+5ojaWDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
@@ -8504,10 +8503,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-compare": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
|
||||
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==",
|
||||
"license": "MIT"
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
|
||||
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -9793,12 +9791,12 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/valtio": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.3.tgz",
|
||||
"integrity": "sha512-q+dVczY5zf7kO39JPVq2qR0VXMeEpF1t+P1njHU7hAhwgtyVa/p38YmgyHZq2IQYwnkNrkvh412k4sfDmcIJ2g==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.2.tgz",
|
||||
"integrity": "sha512-fhekN5Rq7dvHULHHBlJeXHrQDl0Jj9GXfNavCm3gkD06crGchaG1nf/J7gSlfZU2wPcRdVS5jBKWHtE2NNz97A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"proxy-compare": "^3.0.1"
|
||||
"proxy-compare": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
@@ -9892,9 +9890,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
|
||||
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.10.tgz",
|
||||
"integrity": "sha512-MEszunEcMo6pFsfXN1GhCFQqnE25tWRH0MA4f0Q7uanACi4y1Us+ZGpTMnITwCTnYzB2b9cpmnelTlxgTBmaBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10584,10 +10582,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wouter": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.4.0.tgz",
|
||||
"integrity": "sha512-Qx4L+EnYk7AF+pNU9TM+lQtkceL5Qm+oDZU9720DUx+/7K6v7fnlHnRLI78KjEbCqdGFeroW5P0M1t8WYSloQA==",
|
||||
"license": "Unlicense",
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
|
||||
"integrity": "sha512-bx3fLQAMn+EhYbBdY3W1gw9ZfO/uchudxYMwOIBzF3HVgqNEEIT199vEoh7FLTC0Vz5+rpMO6NdFsOkGX1QQCw==",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"regexparam": "^3.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,7 +13,7 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-react": "2.8.0",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
@@ -22,25 +22,25 @@
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.2",
|
||||
"i18next": "24.2.1",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.210.2",
|
||||
"posthog-js": "1.207.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.1.3",
|
||||
"wouter": "3.4.0"
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.11",
|
||||
"vite": "6.0.10",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,32 +121,6 @@ const config: Config = {
|
||||
'50%': { opacity: '0.65' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
rotate: {
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
},
|
||||
prixClipFix: {
|
||||
'0%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0)',
|
||||
},
|
||||
'25%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0)',
|
||||
},
|
||||
'50%': {
|
||||
clipPath:
|
||||
'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%)',
|
||||
},
|
||||
'75%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%)',
|
||||
},
|
||||
'100%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
|
Before Width: | Height: | Size: 958 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 955 KiB |
|
Before Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -13,41 +13,24 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Switch>
|
||||
<Route path="/sdk" nest>
|
||||
<Route path="/create-button">
|
||||
<SdkCreateButton />
|
||||
</Route>
|
||||
</Route>
|
||||
{/* We only want support and ReactQueryDevTools in non /sdk routes */}
|
||||
<Route path="*">
|
||||
<AppInitialization />
|
||||
<Layout>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="top-left"
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
<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} buttonPosition="top-left" />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export const VisioIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.841702 8.95427C0.75 9.42479 0.75 10.0042 0.75 10.9748V13.3096C0.75 14.5138 0.75 15.1159 0.925134 15.6549C1.08009 16.1319 1.33356 16.5709 1.6691 16.9435C2.04833 17.3647 2.56977 17.6658 3.61264 18.2679L5.6346 19.4353C6.55753 19.9681 7.07208 20.2652 7.56247 20.4081V13.2043C7.56247 12.8442 7.36578 12.5129 7.04965 12.3404L0.841702 8.95427Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.747 10.0123V13.7685C17.747 14.2878 17.9727 14.7815 18.3654 15.1214L21.0688 17.4609C21.227 17.5947 21.3912 17.7042 21.5616 17.7894C21.738 17.8685 21.9084 17.9081 22.0726 17.9081C22.4255 17.9081 22.7084 17.7925 22.9213 17.5613C23.1404 17.324 23.2499 17.0168 23.2499 16.6396V7.14866C23.2499 6.77146 23.1404 6.46726 22.9213 6.23607C22.7084 5.9988 22.4255 5.88017 22.0726 5.88017C21.9084 5.88017 21.738 5.91971 21.5616 5.9988C21.3912 6.07789 21.227 6.1874 21.0688 6.32733L18.3675 8.65759C17.9735 8.99746 17.747 9.49201 17.747 10.0123Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1.74517 7.61282C1.67 7.57182 1.59117 7.54405 1.51135 7.52872C1.56171 7.46443 1.61433 7.40178 1.66914 7.34091C2.04837 6.91973 2.56981 6.61868 3.61268 6.01657L5.63464 4.84919C6.67751 4.24708 7.19894 3.94603 7.7533 3.82819C8.2438 3.72394 8.75074 3.72394 9.24124 3.82819C9.7956 3.94603 10.317 4.24708 11.3599 4.84919L13.358 6.0028C13.366 6.00738 13.374 6.01197 13.3819 6.01658C14.4248 6.61868 14.9462 6.91973 15.3255 7.34091C15.661 7.71357 15.9145 8.15259 16.0694 8.62951C16.2446 9.16852 16.2446 9.77063 16.2446 10.9748V13.3096C16.2446 14.5138 16.2446 15.1159 16.0694 15.6549C15.9145 16.1319 15.661 16.5709 15.3255 16.9435C14.9462 17.3647 14.4248 17.6657 13.382 18.2678C13.373 18.273 13.364 18.2782 13.3551 18.2833L11.3599 19.4353C10.317 20.0374 9.7956 20.3384 9.24124 20.4562C9.21846 20.4611 9.19564 20.4657 9.17279 20.4701L9.17278 13.2043C9.17278 12.255 8.65422 11.3814 7.82079 10.9268L1.74517 7.61282Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -10,13 +10,7 @@ import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||
* Here our wrapper just returns false in that case, without triggering an error:
|
||||
* this is done to prevent unnecessary query retries with react query
|
||||
*/
|
||||
export const fetchUser = (
|
||||
opts: {
|
||||
attemptSilent?: boolean
|
||||
} = {
|
||||
attemptSilent: true,
|
||||
}
|
||||
): Promise<ApiUser | false> => {
|
||||
export const fetchUser = (): Promise<ApiUser | false> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetchApi<ApiUser>('/users/me')
|
||||
.then(resolve)
|
||||
@@ -25,7 +19,7 @@ export const fetchUser = (
|
||||
if (error instanceof ApiError && error.statusCode === 401) {
|
||||
// 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 (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
if (canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(300)
|
||||
} else {
|
||||
resolve(false)
|
||||
|
||||
@@ -11,15 +11,10 @@ import { initializeSupportSession } from '@/features/support/hooks/useSupport'
|
||||
*
|
||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||
*/
|
||||
export const useUser = (
|
||||
opts: {
|
||||
fetchUserOptions?: Parameters<typeof fetchUser>[0]
|
||||
} = {}
|
||||
) => {
|
||||
export const useUser = () => {
|
||||
const query = useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: [keys.user],
|
||||
queryFn: () => fetchUser(opts.fetchUserOptions),
|
||||
queryFn: fetchUser,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,16 +9,15 @@ 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 { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -157,18 +156,6 @@ export const Home = () => {
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
/**
|
||||
* Used for SDK popup to close automatically.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
SdkReverseClient.broadcastAuthentication()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -1,69 +1,16 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { Div } from '@/primitives'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant: participant,
|
||||
message: chatMessage.message,
|
||||
type: NotificationType.MessageReceived,
|
||||
},
|
||||
{ timeout: NotificationDuration.MESSAGE }
|
||||
)
|
||||
}
|
||||
room.on(RoomEvent.ChatMessage, handleChatMessage)
|
||||
return () => {
|
||||
room.off(RoomEvent.ChatMessage, handleChatMessage)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const decoder = new TextDecoder()
|
||||
const notificationType = decoder.decode(payload)
|
||||
|
||||
if (!participant) return
|
||||
|
||||
switch (notificationType) {
|
||||
case NotificationType.ParticipantMuted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.ParticipantMuted,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
const showJoinNotification = (participant: Participant) => {
|
||||
if (isMobileBrowser()) {
|
||||
@@ -76,7 +23,7 @@ export const MainNotificationToast = () => {
|
||||
type: NotificationType.ParticipantJoined,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.PARTICIPANT_JOINED,
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -135,7 +82,7 @@ export const MainNotificationToast = () => {
|
||||
participant,
|
||||
type: NotificationType.HandRaised,
|
||||
},
|
||||
{ timeout: NotificationDuration.HAND_RAISED }
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export enum ToastDuration {
|
||||
SHORT = 3000,
|
||||
MEDIUM = 4000,
|
||||
LONG = 5000,
|
||||
EXTRA_LONG = 7000,
|
||||
}
|
||||
|
||||
export const NotificationDuration = {
|
||||
ALERT: ToastDuration.SHORT,
|
||||
MESSAGE: ToastDuration.LONG,
|
||||
PARTICIPANT_JOINED: ToastDuration.LONG,
|
||||
HAND_RAISED: ToastDuration.LONG,
|
||||
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
||||
} as const
|
||||
@@ -1,7 +1,5 @@
|
||||
export enum NotificationType {
|
||||
ParticipantJoined = 'participantJoined',
|
||||
HandRaised = 'handRaised',
|
||||
ParticipantMuted = 'participantMuted',
|
||||
MessageReceived = 'messageReceived',
|
||||
LowerHand = 'lowerHand',
|
||||
// todo - implement message received notification
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
export function ToastLowerHand({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const toast = props.toast
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Clear onClose handler to prevent lowering the hand when user dismisses
|
||||
toast.onClose = undefined
|
||||
state.close(toast.key)
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
<p>{t('auto')}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
marginLeft: '0.5rem',
|
||||
}}
|
||||
onPress={() => handleDismiss()}
|
||||
>
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { Text } from '@/primitives'
|
||||
import { RiMessage2Line } from '@remixicon/react'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ToastMessageReceived({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps } = useToast(props, state, ref)
|
||||
|
||||
const toast = props.toast
|
||||
|
||||
const participant = toast.content.participant
|
||||
const message = toast.content.message
|
||||
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatOpen) {
|
||||
state.close(toast.key)
|
||||
}
|
||||
}, [isChatOpen, toast, state])
|
||||
|
||||
if (isChatOpen) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<RACButton onPress={() => toggleChat()} aria-label={t('openChat')}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
padding: '14px',
|
||||
gap: '0.75rem',
|
||||
textAlign: 'start',
|
||||
width: '150px',
|
||||
md: {
|
||||
width: '260px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'start',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiMessage2Line
|
||||
size={20}
|
||||
className={css({
|
||||
color: 'primary.300',
|
||||
marginTop: '3px',
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{participant.name}</span>
|
||||
</div>
|
||||
<Text margin={false} centered={false} wrap={'pretty'} fullWidth>
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</RACButton>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ToastMuted({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t('muted', { name: participant.name })}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,30 @@
|
||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
||||
import type { QueuedToast, ToastState } from '@react-stately/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 './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
}
|
||||
|
||||
const renderToast = (
|
||||
toast: QueuedToast<ToastData>,
|
||||
state: ToastState<ToastData>
|
||||
) => {
|
||||
switch (toast.content?.type) {
|
||||
case NotificationType.ParticipantJoined:
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.HandRaised:
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.ParticipantMuted:
|
||||
return <ToastMuted key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.MessageReceived:
|
||||
return (
|
||||
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.LowerHand:
|
||||
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
}
|
||||
|
||||
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) => renderToast(toast, state))}
|
||||
{state.visibleToasts.map((toast) => {
|
||||
if (toast.content?.type === NotificationType.ParticipantJoined) {
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.HandRaised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
export const showLowerHandToast = (
|
||||
participant: Participant,
|
||||
onClose: () => void
|
||||
) => {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.LowerHand,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.LOWER_HAND,
|
||||
onClose,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const closeLowerHandToasts = () => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
if (toast.content.type === NotificationType.LowerHand) {
|
||||
toastQueue.close(toast.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LiveKitRoom } from '@livekit/components-react'
|
||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -12,11 +12,10 @@ import { fetchRoom } from '../api/fetchRoom'
|
||||
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'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LocalUserChoices } from '../routes/Room'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -32,7 +31,7 @@ export const Conference = ({
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
@@ -49,7 +48,6 @@ export const Conference = ({
|
||||
isError: isFetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: fetchKey,
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
initialData: initialRoomData,
|
||||
@@ -113,13 +111,7 @@ export const Conference = ({
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
userConfig.processorSerialized
|
||||
),
|
||||
}
|
||||
}
|
||||
video={userConfig.videoEnabled}
|
||||
connectOptions={connectOptions}
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
|
||||
@@ -1,162 +1,119 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import {
|
||||
ParticipantPlaceholder,
|
||||
usePersistentUserChoices,
|
||||
usePreviewTracks,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { log } from '@livekit/components-core'
|
||||
import { defaultUserChoices } from '@livekit/components-core'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { useUser } from '@/features/auth'
|
||||
import React from 'react'
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { LocalUserChoices } from '../routes/Room'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
import {
|
||||
EffectsConfiguration,
|
||||
EffectsConfigurationProps,
|
||||
} from '../livekit/components/effects/EffectsConfiguration'
|
||||
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
const Effects = ({
|
||||
videoTrack,
|
||||
onSubmit,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const openDialog = () => setIsDialogOpen(true)
|
||||
|
||||
if (!BackgroundProcessorFactory.isSupported() || isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
isOpen={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
size="large"
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={css({
|
||||
textStyle: 'h1',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<Text
|
||||
variant="subTitle"
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{t('subTitle')}
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
</Dialog>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: '0',
|
||||
padding: '1rem',
|
||||
zIndex: '1',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
onPress={openDialog}
|
||||
tooltip={t('description')}
|
||||
aria-label={t('description')}
|
||||
>
|
||||
<RiImageCircleAiFill size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '20%',
|
||||
backgroundImage:
|
||||
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
|
||||
borderBottomRadius: '1rem',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const { t } = useTranslation('rooms')
|
||||
const { user } = useUser()
|
||||
const defaults: Partial<LocalUserChoices> = { username: user?.full_name }
|
||||
const persistUserChoices = true
|
||||
const joinLabel = t('join.joinLabel')
|
||||
const userLabel = t('join.usernameLabel')
|
||||
|
||||
const [userChoices, setUserChoices] = React.useState(defaultUserChoices)
|
||||
|
||||
// TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`.
|
||||
const partialDefaults: Partial<LocalUserChoices> = {
|
||||
...(defaults.audioDeviceId !== undefined && {
|
||||
audioDeviceId: defaults.audioDeviceId,
|
||||
}),
|
||||
...(defaults.videoDeviceId !== undefined && {
|
||||
videoDeviceId: defaults.videoDeviceId,
|
||||
}),
|
||||
...(defaults.audioEnabled !== undefined && {
|
||||
audioEnabled: defaults.audioEnabled,
|
||||
}),
|
||||
...(defaults.videoEnabled !== undefined && {
|
||||
videoEnabled: defaults.videoEnabled,
|
||||
}),
|
||||
...(defaults.username !== undefined && { username: defaults.username }),
|
||||
}
|
||||
|
||||
const {
|
||||
userChoices: initialUserChoices,
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices({})
|
||||
} = usePersistentUserChoices({
|
||||
defaults: partialDefaults,
|
||||
preventSave: !persistUserChoices,
|
||||
preventLoad: !persistUserChoices,
|
||||
})
|
||||
|
||||
const [audioEnabled, setAudioEnabled] = useState(true)
|
||||
const [videoEnabled, setVideoEnabled] = useState(true)
|
||||
const [audioDeviceId, setAudioDeviceId] = useState<string>(
|
||||
// Initialize device settings
|
||||
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(
|
||||
initialUserChoices.audioEnabled
|
||||
)
|
||||
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(
|
||||
initialUserChoices.videoEnabled
|
||||
)
|
||||
const [audioDeviceId, setAudioDeviceId] = React.useState<string>(
|
||||
initialUserChoices.audioDeviceId
|
||||
)
|
||||
const [videoDeviceId, setVideoDeviceId] = useState<string>(
|
||||
const [videoDeviceId, setVideoDeviceId] = React.useState<string>(
|
||||
initialUserChoices.videoDeviceId
|
||||
)
|
||||
const [username, setUsername] = useState<string>(initialUserChoices.username)
|
||||
const [processor, setProcessor] = useState(
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
initialUserChoices.processorSerialized
|
||||
)
|
||||
)
|
||||
const [username, setUsername] = React.useState(initialUserChoices.username)
|
||||
|
||||
useEffect(() => {
|
||||
// Save user choices to persistent storage.
|
||||
React.useEffect(() => {
|
||||
saveAudioInputEnabled(audioEnabled)
|
||||
}, [audioEnabled, saveAudioInputEnabled])
|
||||
React.useEffect(() => {
|
||||
saveVideoInputEnabled(videoEnabled)
|
||||
}, [videoEnabled, saveVideoInputEnabled])
|
||||
React.useEffect(() => {
|
||||
saveAudioInputDeviceId(audioDeviceId)
|
||||
}, [audioDeviceId, saveAudioInputDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
saveVideoInputDeviceId(videoDeviceId)
|
||||
}, [videoDeviceId, saveVideoInputDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
saveUsername(username)
|
||||
}, [username, saveUsername])
|
||||
|
||||
useEffect(() => {
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}, [
|
||||
processor,
|
||||
saveProcessorSerialized,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(processor?.serialize()),
|
||||
])
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: { deviceId: initialUserChoices.audioDeviceId },
|
||||
video: { deviceId: initialUserChoices.videoDeviceId },
|
||||
audio: audioEnabled
|
||||
? { deviceId: initialUserChoices.audioDeviceId }
|
||||
: false,
|
||||
video: videoEnabled
|
||||
? { deviceId: initialUserChoices.videoDeviceId }
|
||||
: false,
|
||||
},
|
||||
onError
|
||||
)
|
||||
|
||||
const videoTrack = useMemo(
|
||||
const videoEl = React.useRef(null)
|
||||
|
||||
const videoTrack = React.useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
@@ -164,7 +121,7 @@ export const Join = ({
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const audioTrack = useMemo(
|
||||
const audioTrack = React.useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
@@ -172,49 +129,60 @@ export const Join = ({
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const videoEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoEl.current as HTMLVideoElement | null
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
if (videoElement) {
|
||||
videoElement.style.opacity = '1'
|
||||
}
|
||||
const facingMode = React.useMemo(() => {
|
||||
if (videoTrack) {
|
||||
const { facingMode } = facingModeFromLocalTrack(videoTrack)
|
||||
return facingMode
|
||||
} else {
|
||||
return 'undefined'
|
||||
}
|
||||
}, [videoTrack])
|
||||
|
||||
if (videoElement && videoTrack && videoEnabled) {
|
||||
React.useEffect(() => {
|
||||
if (videoEl.current && videoTrack) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoElement)
|
||||
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||
videoTrack.attach(videoEl.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoTrack?.detach()
|
||||
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
}, [videoTrack])
|
||||
|
||||
const [isValid, setIsValid] = React.useState<boolean>()
|
||||
|
||||
const handleValidation = React.useCallback((values: LocalUserChoices) => {
|
||||
return values.username !== ''
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const newUserChoices = {
|
||||
username,
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
audioEnabled,
|
||||
audioDeviceId,
|
||||
}
|
||||
setUserChoices(newUserChoices)
|
||||
setIsValid(handleValidation(newUserChoices))
|
||||
}, [
|
||||
username,
|
||||
videoEnabled,
|
||||
handleValidation,
|
||||
audioEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
])
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processorSerialized: processor?.serialize(),
|
||||
})
|
||||
}
|
||||
|
||||
// This hook is used to setup the persisted user choice processor on initialization.
|
||||
// So it's on purpose that processor is not included in the deps.
|
||||
// We just want to wait for the videoTrack to be loaded to apply the default processor.
|
||||
useEffect(() => {
|
||||
if (processor && videoTrack && !videoTrack.getProcessor()) {
|
||||
videoTrack.setProcessor(processor)
|
||||
if (handleValidation(userChoices)) {
|
||||
if (typeof onSubmit === 'function') {
|
||||
onSubmit(userChoices)
|
||||
}
|
||||
} else {
|
||||
log.warn('Validation failed with: ', userChoices)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoTrack])
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen footer={false}>
|
||||
@@ -264,63 +232,67 @@ export const Join = ({
|
||||
height: 'auto',
|
||||
aspectRatio: 16 / 9,
|
||||
'--tw-shadow':
|
||||
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
|
||||
'0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a',
|
||||
'--tw-shadow-colored':
|
||||
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||
'0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||
boxShadow:
|
||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
||||
backgroundColor: 'black',
|
||||
})}
|
||||
>
|
||||
{videoTrack && videoEnabled ? (
|
||||
{videoTrack && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
data-lk-facing-mode={facingMode}
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
borderRadius: '1rem',
|
||||
})}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{(!videoTrack || !videoEnabled) && (
|
||||
<div
|
||||
id="container"
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#000',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
<ParticipantPlaceholder
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: '300',
|
||||
maxWidth: '100%',
|
||||
height: '70%',
|
||||
})}
|
||||
>
|
||||
{!videoEnabled && t('cameraDisabled')}
|
||||
{videoEnabled && !videoTrack && t('cameraStarting')}
|
||||
</p>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
||||
<div className="lk-button-group-container"></div>
|
||||
</div>
|
||||
<HStack justify="center" padding={1.5}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1.5rem',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
initialDeviceId={initialUserChoices.audioDeviceId}
|
||||
initialDeviceId={audioDeviceId}
|
||||
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
@@ -332,16 +304,19 @@ export const Join = ({
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
initialDeviceId={initialUserChoices.videoDeviceId}
|
||||
onChange={(enabled) => setVideoEnabled(enabled)}
|
||||
initialDeviceId={videoDeviceId}
|
||||
onChange={(enabled) => {
|
||||
setVideoEnabled(enabled)
|
||||
}}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setVideoDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
@@ -353,35 +328,48 @@ export const Join = ({
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
<form
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin={false}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
</Form>
|
||||
<H lvl={1} className={css({ marginBottom: 0 })}>
|
||||
{t('join.heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
label={userLabel}
|
||||
defaultValue={username}
|
||||
onChange={(value) => setUsername(value)}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('join.errors.usernameEmpty')}</p> : null
|
||||
}}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={'primary'}
|
||||
onPress={handleSubmit}
|
||||
isDisabled={!isValid}
|
||||
fullWidth
|
||||
>
|
||||
{joinLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -171,7 +170,7 @@ const RateQuality = ({
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<Bar>
|
||||
{[...Array(maxRating)].map((_, index) => (
|
||||
<RACButton
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => handleRatingClick(index + 1)}
|
||||
className={ratingButtonRecipe({
|
||||
@@ -180,7 +179,7 @@ const RateQuality = ({
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</RACButton>
|
||||
</Button>
|
||||
))}
|
||||
</Bar>
|
||||
<div
|
||||
|
||||
@@ -3,23 +3,11 @@ import Source = Track.Source
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
const room = useRoomContext()
|
||||
|
||||
const notifyParticipant = async (participant: Participant) => {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(NotificationType.ParticipantMuted)
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
}
|
||||
|
||||
const muteParticipant = async (participant: Participant) => {
|
||||
const muteParticipant = (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
@@ -30,33 +18,22 @@ export const useMuteParticipant = () => {
|
||||
if (!trackSid) {
|
||||
throw new Error('Missing audio track')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
await notifyParticipant(participant)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
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,188 @@
|
||||
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 {
|
||||
BackgroundBlurFactory,
|
||||
BackgroundBlurProcessorInterface,
|
||||
} from './blur/index'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundBlurFactory.isSupported()
|
||||
|
||||
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 BackgroundBlurProcessorInterface
|
||||
}
|
||||
|
||||
const getBlurRadius = (): BlurRadius => {
|
||||
const processor = getProcessor()
|
||||
return processor?.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(
|
||||
BackgroundBlurFactory.getProcessor({ blurRadius })!
|
||||
)
|
||||
} else {
|
||||
processor?.update({ 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" overflowY="scroll">
|
||||
{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>
|
||||
{isSupported ? (
|
||||
<HStack>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
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'}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const FullScreenShareWarning = ({
|
||||
trackReference,
|
||||
}: {
|
||||
trackReference: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
||||
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
||||
|
||||
const isFullScreenCapture = useMemo(() => {
|
||||
const trackLabel =
|
||||
trackReference.publication?.track?.mediaStreamTrack?.label
|
||||
return trackLabel?.includes('screen')
|
||||
}, [trackReference])
|
||||
|
||||
const shouldShowWarning =
|
||||
screenSharePreferences.enabled && isFullScreenCapture
|
||||
|
||||
const handleStopScreenShare = async () => {
|
||||
if (!localParticipant.isScreenShareEnabled) return
|
||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||
}
|
||||
|
||||
const handleDismissWarning = () => {
|
||||
ScreenSharePreferenceStore.enabled = false
|
||||
}
|
||||
|
||||
if (!shouldShowWarning) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})}
|
||||
ref={warningContainerRef}
|
||||
>
|
||||
{(!containerWidth || containerWidth >= 300) && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: 'rgba(22, 22, 34, 0.9)',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: 'white',
|
||||
flexBasis: '55%',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
margin={false}
|
||||
wrap={'pretty'}
|
||||
>
|
||||
{t('message')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
style={{
|
||||
height: 'fit-content',
|
||||
}}
|
||||
onPress={async () => {
|
||||
await handleStopScreenShare()
|
||||
}}
|
||||
>
|
||||
{t('stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
style={{
|
||||
height: 'fit-content',
|
||||
}}
|
||||
onPress={() => handleDismissWarning()}
|
||||
>
|
||||
{t('ignore')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Dialog, P } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
export const MuteAlertDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
name,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
name: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'participants.muteParticipantAlert',
|
||||
})
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('heading', { name })}
|
||||
>
|
||||
<P>{t('description', { name })}</P>
|
||||
<HStack gap={1}>
|
||||
<Button variant="text" size="sm" onPress={onClose}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button variant="text" size="sm" onPress={onSubmit}>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
FocusToggle,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
@@ -22,13 +23,11 @@ import {
|
||||
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 '../hooks/useRaisedHand'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -101,7 +100,6 @@ export const ParticipantTile: (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
@@ -175,9 +173,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
<ParticipantTileFocus trackRef={trackReference} />
|
||||
)}
|
||||
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
</div>
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import {
|
||||
RiFullscreenLine,
|
||||
RiImageCircleAiFill,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiPushpin2Line,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useFocusToggle,
|
||||
useTrackMutedIndicator,
|
||||
} from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useFullScreen } from '../hooks/useFullScreen'
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||
import { useMuteParticipant } from '../api/muteParticipant'
|
||||
|
||||
const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const FocusButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { mergedProps, inFocus } = useFocusToggle({
|
||||
trackRef,
|
||||
props: {},
|
||||
})
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onPress={(event) => mergedProps?.onClick?.(event as any)}
|
||||
>
|
||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const EffectsButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||
>
|
||||
<RiImageCircleAiFill />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered && !isIdleRef) {
|
||||
// Wait for next frame to ensure element is mounted
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(0.6)
|
||||
})
|
||||
} else {
|
||||
setOpacity(0)
|
||||
}
|
||||
}, [hovered, isIdleRef])
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (idleTimerRef.current) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setIsIdleRef(true)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
setIsIdleRef(false)
|
||||
}
|
||||
|
||||
const participant = trackRef.participant
|
||||
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = trackRef.participant.isLocal
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{hovered && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
transition: 'opacity 200ms linear',
|
||||
zIndex: 1,
|
||||
borderRadius: '0.25rem',
|
||||
display: 'flex',
|
||||
_hover: {
|
||||
opacity: '0.95 !important',
|
||||
},
|
||||
})}
|
||||
style={{ opacity }}
|
||||
>
|
||||
<HStack
|
||||
gap={0.5}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
_hover: {
|
||||
opacity: '1 !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
<>
|
||||
{participant.isLocal ? (
|
||||
<EffectsButton />
|
||||
) : (
|
||||
<MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { Effects } from './Effects'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
import { Effects } from './effects/Effects'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -32,7 +28,9 @@ const DEFAULT_BLUR = '10'
|
||||
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
|
||||
* MediaStreamTrackProcessor.
|
||||
*/
|
||||
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
export class BackgroundBlurCustomProcessor
|
||||
implements BackgroundBlurProcessorInterface
|
||||
{
|
||||
options: BackgroundOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
@@ -61,18 +59,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
|
||||
timerWorker?: Worker
|
||||
|
||||
type: ProcessorType
|
||||
virtualBackgroundImage?: HTMLImageElement
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.name = 'blur'
|
||||
this.options = opts
|
||||
|
||||
if (this.options.blurRadius) {
|
||||
this.type = ProcessorType.BLUR
|
||||
} else {
|
||||
this.type = ProcessorType.VIRTUAL
|
||||
}
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
@@ -88,7 +77,6 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
this._initVirtualBackgroundImage()
|
||||
this._createMainCanvas()
|
||||
this._createMaskCanvas()
|
||||
|
||||
@@ -106,21 +94,8 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
posthog.capture('firefox-blurring-init')
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
const needsUpdate =
|
||||
this.options.imagePath &&
|
||||
this.virtualBackgroundImage &&
|
||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||
if (this.options.imagePath || needsUpdate) {
|
||||
this.virtualBackgroundImage = document.createElement('img')
|
||||
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
||||
}
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.options = opts
|
||||
this._initVirtualBackgroundImage()
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
@@ -222,7 +197,6 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
|
||||
this.outputCanvasCtx!.filter = 'blur(8px)'
|
||||
|
||||
// Put opacity mask.
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
this.segmentationMaskCanvas!,
|
||||
0,
|
||||
@@ -235,69 +209,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.videoElement!.videoHeight
|
||||
)
|
||||
|
||||
// Draw clear body.
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
|
||||
this.outputCanvasCtx!.filter = 'none'
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
|
||||
// Draw blurry background.
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
|
||||
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: future improvement with WebGL.
|
||||
*/
|
||||
async drawVirtualBackground() {
|
||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||
for (let i = 0; i < mask.length; ++i) {
|
||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||
}
|
||||
|
||||
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
|
||||
this.outputCanvasCtx!.filter = 'blur(8px)'
|
||||
|
||||
// Put opacity mask.
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
this.segmentationMaskCanvas!,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight
|
||||
)
|
||||
|
||||
// Draw clear body.
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
|
||||
this.outputCanvasCtx!.filter = 'none'
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
|
||||
// Draw virtual background.
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
this.virtualBackgroundImage!,
|
||||
0,
|
||||
0,
|
||||
this.outputCanvas!.width,
|
||||
this.outputCanvas!.height
|
||||
)
|
||||
}
|
||||
|
||||
async process() {
|
||||
await this.sizeSource()
|
||||
await this.segment()
|
||||
|
||||
if (this.options.blurRadius) {
|
||||
await this.blur()
|
||||
} else {
|
||||
await this.drawVirtualBackground()
|
||||
}
|
||||
await this.blur()
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
@@ -354,15 +278,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.timerWorker?.terminate()
|
||||
this.imageSegmenter?.close()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new BackgroundCustomProcessor(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
ProcessorWrapper,
|
||||
} from '@livekit/track-processors'
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
|
||||
|
||||
/**
|
||||
* This is simply a wrapper around track-processor-js Processor
|
||||
@@ -16,17 +12,14 @@ import {
|
||||
* used accross the project.
|
||||
*/
|
||||
export class BackgroundBlurTrackProcessorJsWrapper
|
||||
implements BackgroundProcessorInterface
|
||||
implements BackgroundBlurProcessorInterface
|
||||
{
|
||||
name: string = 'blur'
|
||||
name: string = 'Blur'
|
||||
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
|
||||
opts: BackgroundOptions
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
this.opts = opts
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
@@ -52,17 +45,4 @@ export class BackgroundBlurTrackProcessorJsWrapper
|
||||
get options() {
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new BackgroundBlurTrackProcessorJsWrapper({
|
||||
blurRadius: this.options!.blurRadius,
|
||||
})
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: ProcessorType.BLUR,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundOptions,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
import {
|
||||
BackgroundTransformer,
|
||||
ProcessorWrapper,
|
||||
VirtualBackground,
|
||||
} from '@livekit/track-processors'
|
||||
|
||||
export class BackgroundVirtualTrackProcessorJsWrapper
|
||||
implements BackgroundProcessorInterface
|
||||
{
|
||||
name = 'virtual'
|
||||
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
|
||||
opts: BackgroundOptions
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.processor = VirtualBackground(opts.imagePath!)
|
||||
this.opts = opts
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.init(opts)
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.restart(opts)
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
return this.processor.destroy()
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.processor.updateTransformerOptions(opts)
|
||||
}
|
||||
|
||||
get processedTrack() {
|
||||
return this.processor.processedTrack
|
||||
}
|
||||
|
||||
get options() {
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new BackgroundVirtualTrackProcessorJsWrapper(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,32 @@
|
||||
import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
|
||||
import { BackgroundBlurCustomProcessor } from './BackgroundBlurCustomProcessor'
|
||||
|
||||
export type BackgroundOptions = {
|
||||
blurRadius?: number
|
||||
imagePath?: string
|
||||
}
|
||||
|
||||
export interface ProcessorSerialized {
|
||||
type: ProcessorType
|
||||
options: BackgroundOptions
|
||||
}
|
||||
|
||||
export interface BackgroundProcessorInterface
|
||||
export interface BackgroundBlurProcessorInterface
|
||||
extends TrackProcessor<Track.Kind> {
|
||||
update(opts: BackgroundOptions): void
|
||||
options: BackgroundOptions
|
||||
clone(): BackgroundProcessorInterface
|
||||
serialize(): ProcessorSerialized
|
||||
}
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
VIRTUAL = 'virtual',
|
||||
}
|
||||
|
||||
export class BackgroundProcessorFactory {
|
||||
export class BackgroundBlurFactory {
|
||||
static isSupported() {
|
||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||
return (
|
||||
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
|
||||
)
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
type: ProcessorType,
|
||||
opts: BackgroundOptions
|
||||
): BackgroundProcessorInterface | undefined {
|
||||
if (type === ProcessorType.BLUR) {
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new BackgroundBlurTrackProcessorJsWrapper(opts)
|
||||
}
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
}
|
||||
} else if (type === ProcessorType.VIRTUAL) {
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new BackgroundVirtualTrackProcessorJsWrapper(opts)
|
||||
}
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
}
|
||||
static getProcessor(opts: BackgroundOptions) {
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new BackgroundBlurTrackProcessorJsWrapper(opts)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
static deserializeProcessor(data?: ProcessorSerialized) {
|
||||
if (data?.type) {
|
||||
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
|
||||
if (BackgroundBlurCustomProcessor.isSupported) {
|
||||
return new BackgroundBlurCustomProcessor(opts)
|
||||
}
|
||||
return undefined
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,6 @@ import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
closeLowerHandToasts,
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
export const HandToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
@@ -20,39 +13,6 @@ export const HandToggle = () => {
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
|
||||
const isSpeaking = room.localParticipant.isSpeaking
|
||||
const speakingTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [hasShownToast, setHasShownToast] = useState(false)
|
||||
|
||||
const resetToastState = () => {
|
||||
setHasShownToast(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isHandRaised) return
|
||||
closeLowerHandToasts()
|
||||
}, [isHandRaised])
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
|
||||
|
||||
if (shouldShowToast && !speakingTimerRef.current) {
|
||||
speakingTimerRef.current = setTimeout(() => {
|
||||
setHasShownToast(true)
|
||||
const onClose = () => {
|
||||
if (isHandRaised) toggleRaisedHand()
|
||||
resetToastState()
|
||||
}
|
||||
showLowerHandToast(room.localParticipant, onClose)
|
||||
}, SPEAKING_DETECTION_DELAY)
|
||||
}
|
||||
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
|
||||
clearTimeout(speakingTimerRef.current)
|
||||
speakingTimerRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
|
||||
|
||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
||||
|
||||
return (
|
||||
@@ -68,10 +28,7 @@ export const HandToggle = () => {
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={() => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}}
|
||||
onPress={() => toggleRaisedHand()}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
|
||||
export const EffectsMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useSidePanel()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiImageCircleAiFill size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedback')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useFullScreen } from '../../../hooks/useFullScreen'
|
||||
|
||||
export const FullScreenMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleFullScreen, isCurrentlyFullscreen, isFullscreenAvailable } =
|
||||
useFullScreen({})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={() => toggleFullScreen()}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
{isCurrentlyFullscreen ? (
|
||||
<>
|
||||
<RiFullscreenExitLine size={20} />
|
||||
{t('fullscreen.exit')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFullscreenLine size={20} />
|
||||
{t('fullscreen.enter')}
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import {
|
||||
RiAccountBoxLine,
|
||||
RiMegaphoneLine,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { FullScreenMenuItem } from './FullScreenMenuItem'
|
||||
import { SettingsMenuItem } from './SettingsMenuItem'
|
||||
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
||||
import { EffectsMenuItem } from './EffectsMenuItem'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
@@ -15,13 +24,31 @@ export const OptionsMenuItems = () => {
|
||||
}}
|
||||
>
|
||||
<MenuSection>
|
||||
<FullScreenMenuItem />
|
||||
<EffectsMenuItem />
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<MenuSection>
|
||||
<FeedbackMenuItem />
|
||||
<SettingsMenuItem />
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedback')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
</RACMenu>
|
||||
)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { RiSettings3Line } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
|
||||
export const SettingsMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,36 @@ import {
|
||||
} from '@livekit/components-react'
|
||||
import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { Button, Dialog, P } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { useMuteParticipant } from '@/features/rooms/livekit/api/muteParticipant'
|
||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||
|
||||
const MuteAlertDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
name,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
name: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
return (
|
||||
<Dialog isOpen={isOpen} role="alertdialog">
|
||||
<P>{t('participants.muteParticipantAlert.description', { name })}</P>
|
||||
<HStack gap={1}>
|
||||
<Button variant="text" size="sm" onPress={onClose}>
|
||||
{t('participants.muteParticipantAlert.cancel')}
|
||||
</Button>
|
||||
<Button variant="text" size="sm" onPress={onSubmit}>
|
||||
{t('participants.muteParticipantAlert.confirm')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
VideoCaptureOptions,
|
||||
} from 'livekit-client'
|
||||
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
|
||||
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
@@ -26,8 +21,6 @@ import { ToggleDevice } from '@/features/rooms/livekit/components/controls/Toggl
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { useEffect } from 'react'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../blur'
|
||||
|
||||
export type ToggleSource = Exclude<
|
||||
Track.Source,
|
||||
@@ -99,39 +92,6 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const trackProps = useTrackToggle(props)
|
||||
|
||||
const { userChoices } = usePersistentUserChoices({})
|
||||
|
||||
const toggle = () => {
|
||||
if (props.source === Track.Source.Camera) {
|
||||
/**
|
||||
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
||||
* Before, we had the following bug:
|
||||
* 1 - Configure a processor on join screen
|
||||
* 2 - Turn off camera on join screen
|
||||
* 3 - Join the room
|
||||
* 4 - Turn on the camera
|
||||
* 5 - No processor is applied to the camera
|
||||
* Expected: The processor is applied.
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
forceState: boolean,
|
||||
captureOptions: VideoCaptureOptions
|
||||
) => Promise<void>
|
||||
|
||||
toggle(!trackProps.enabled, {
|
||||
processor: processor,
|
||||
} as VideoCaptureOptions)
|
||||
} else {
|
||||
trackProps.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: config.kind, track })
|
||||
|
||||
@@ -160,7 +120,6 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
{...trackProps}
|
||||
config={config}
|
||||
variant={variant}
|
||||
toggle={toggle}
|
||||
toggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
|
||||
@@ -4,14 +4,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
|
||||
const [isOpened, setIsOpened] = useState(() => {
|
||||
return window?.$crisp?.is?.('chat:opened') || false
|
||||
})
|
||||
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!Crisp) {
|
||||
@@ -30,12 +26,6 @@ export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isSupportEnabled = useIsSupportEnabled()
|
||||
|
||||
if (!isSupportEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
square
|
||||
|
||||
@@ -7,7 +7,7 @@ import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const TranscriptToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { LocalVideoTrack } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { EffectsConfiguration } from './EffectsConfiguration'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
|
||||
export const Effects = () => {
|
||||
const { cameraTrack } = useLocalParticipant()
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '0 1.5rem',
|
||||
})}
|
||||
>
|
||||
<EffectsConfiguration
|
||||
videoTrack={localCameraTrack}
|
||||
layout="vertical"
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
BackgroundProcessorFactory,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text, P, ToggleButton, H } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { BackgroundOptions } from '@livekit/track-processors'
|
||||
import { BlurOn } from '@/components/icons/BlurOn'
|
||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
LIGHT = 5,
|
||||
NORMAL = 10,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||
|
||||
const Information = styled('div', {
|
||||
base: {
|
||||
backgroundColor: 'orange.50',
|
||||
borderRadius: '4px',
|
||||
padding: '0.75rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
},
|
||||
})
|
||||
|
||||
export type EffectsConfigurationProps = {
|
||||
videoTrack: LocalVideoTrack
|
||||
onSubmit?: (processor?: BackgroundProcessorInterface) => void
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
export const EffectsConfiguration = ({
|
||||
videoTrack,
|
||||
onSubmit,
|
||||
layout = 'horizontal',
|
||||
}: EffectsConfigurationProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
if (!videoElement) return
|
||||
|
||||
const attachVideoTrack = async () => videoTrack?.attach(videoElement)
|
||||
attachVideoTrack()
|
||||
|
||||
return () => {
|
||||
if (!videoElement) return
|
||||
videoTrack.detach(videoElement)
|
||||
}
|
||||
}, [videoTrack, videoTrack?.isMuted])
|
||||
|
||||
const toggleEffect = async (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions
|
||||
) => {
|
||||
setProcessorPending(true)
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
|
||||
* setProccesor on.
|
||||
*
|
||||
* We arrive in this condition when we enter the room with the camera already off.
|
||||
*/
|
||||
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(
|
||||
type,
|
||||
options
|
||||
)!
|
||||
await toggle(true, {
|
||||
processor: newProcessorTmp,
|
||||
})
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
await toggle(true)
|
||||
}
|
||||
|
||||
const processor = getProcessor()
|
||||
try {
|
||||
if (isSelected(type, options)) {
|
||||
// Stop processor.
|
||||
await videoTrack.stopProcessor()
|
||||
onSubmit?.(undefined)
|
||||
} else if (!processor || processor.serialize().type !== type) {
|
||||
// Change processor.
|
||||
const newProcessor = BackgroundProcessorFactory.getProcessor(
|
||||
type,
|
||||
options
|
||||
)!
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
onSubmit?.(newProcessor)
|
||||
} else {
|
||||
// Update processor.
|
||||
processor?.update(options)
|
||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
}
|
||||
}
|
||||
|
||||
const getProcessor = () => {
|
||||
return videoTrack?.getProcessor() as BackgroundProcessorInterface
|
||||
}
|
||||
|
||||
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const processor = getProcessor()
|
||||
const processorSerialized = processor?.serialize()
|
||||
return (
|
||||
!!processor &&
|
||||
processorSerialized.type === type &&
|
||||
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
layout === 'vertical'
|
||||
? {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5rem',
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{videoTrack && !videoTrack.isMuted ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
width="100%"
|
||||
muted
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
minHeight: '175px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
backgroundColor: 'black',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<P
|
||||
style={{
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{t('activateCamera')}
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
{processorPendingReveal && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
bottom: '8px',
|
||||
})}
|
||||
>
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css(
|
||||
layout === 'horizontal'
|
||||
? {
|
||||
md: {
|
||||
borderLeft: '1px solid #dadce0',
|
||||
paddingLeft: '1.5rem',
|
||||
width: '420px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
}
|
||||
: {}
|
||||
)}
|
||||
>
|
||||
{isSupported ? (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('blur.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('virtual.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
return (
|
||||
<ToggleButton
|
||||
key={i}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
></ToggleButton>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Information className={css({ marginTop: '1rem' })}>
|
||||
<Text variant="sm">⚠︎ {t('experimental')}</Text>
|
||||
</Information>
|
||||
</>
|
||||
) : (
|
||||
<Information>
|
||||
<Text variant="sm">{t('notAvailable')}</Text>
|
||||
</Information>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// We use vendor prefix properties
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
|
||||
export function useFullScreen({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef?: TrackReferenceOrPlaceholder
|
||||
}) {
|
||||
const videoElement = useMemo(() => {
|
||||
const elements = trackRef?.publication?.track?.attachedElements
|
||||
|
||||
if (!elements) return
|
||||
|
||||
// Find the visible video element
|
||||
const likeKitElement = elements.find((el) =>
|
||||
el.classList.contains('lk-participant-media-video')
|
||||
)
|
||||
|
||||
if (!likeKitElement) {
|
||||
console.warn('Could not find LiveKit-managed video element')
|
||||
return elements[0] || null
|
||||
}
|
||||
|
||||
return likeKitElement
|
||||
}, [trackRef])
|
||||
|
||||
const getIsFullscreen = () => {
|
||||
return !!(
|
||||
document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement
|
||||
)
|
||||
}
|
||||
|
||||
const [isFullscreenAvailable] = useState(
|
||||
() =>
|
||||
document.fullscreenEnabled ||
|
||||
document.webkitFullscreenEnabled ||
|
||||
document.mozFullScreenEnabled ||
|
||||
document.msFullscreenEnabled
|
||||
)
|
||||
|
||||
const enterFullscreen = async () => {
|
||||
try {
|
||||
const docEl = videoElement || document.documentElement
|
||||
if (docEl.requestFullscreen) {
|
||||
await docEl.requestFullscreen()
|
||||
} else if (docEl.webkitRequestFullscreen) {
|
||||
await docEl.webkitRequestFullscreen()
|
||||
} else if (docEl.msRequestFullscreen) {
|
||||
await docEl.msRequestFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error entering fullscreen:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const exitFullscreen = async () => {
|
||||
try {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen()
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
await document.webkitExitFullscreen()
|
||||
} else if (document.msExitFullscreen) {
|
||||
await document.msExitFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error exiting fullscreen:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullScreen = async () => {
|
||||
const isCurrentlyFullscreen = getIsFullscreen()
|
||||
if (isCurrentlyFullscreen) {
|
||||
await exitFullscreen()
|
||||
} else {
|
||||
await enterFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isCurrentlyFullscreen: getIsFullscreen(),
|
||||
isFullscreenAvailable,
|
||||
toggleFullScreen,
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { UsePersistentUserChoicesOptions } from '@livekit/components-react'
|
||||
import React from 'react'
|
||||
import { LocalUserChoices } from '../../routes/Room'
|
||||
import { saveUserChoices, loadUserChoices } from '@livekit/components-core'
|
||||
import { ProcessorSerialized } from '../components/blur'
|
||||
|
||||
/**
|
||||
* From @livekit/component-react
|
||||
*
|
||||
* A hook that provides access to user choices stored in local storage, such as
|
||||
* selected media devices and their current state (on or off), as well as the user name.
|
||||
* @alpha
|
||||
*/
|
||||
export function usePersistentUserChoices(
|
||||
options: UsePersistentUserChoicesOptions = {}
|
||||
) {
|
||||
const [userChoices, setSettings] = React.useState<LocalUserChoices>(
|
||||
loadUserChoices(options.defaults, options.preventLoad ?? false)
|
||||
)
|
||||
|
||||
const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, audioEnabled: isEnabled }))
|
||||
}, [])
|
||||
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, videoEnabled: isEnabled }))
|
||||
}, [])
|
||||
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
|
||||
setSettings((prev) => ({ ...prev, audioDeviceId: deviceId }))
|
||||
}, [])
|
||||
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
|
||||
setSettings((prev) => ({ ...prev, videoDeviceId: deviceId }))
|
||||
}, [])
|
||||
const saveUsername = React.useCallback((username: string) => {
|
||||
setSettings((prev) => ({ ...prev, username: username }))
|
||||
}, [])
|
||||
const saveProcessorSerialized = React.useCallback(
|
||||
(processorSerialized?: ProcessorSerialized) => {
|
||||
setSettings((prev) => ({ ...prev, processorSerialized }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
saveUserChoices(userChoices, options.preventSave ?? false)
|
||||
}, [userChoices, options.preventSave])
|
||||
|
||||
return {
|
||||
userChoices,
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,5 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return { isHandRaised: parsedMetadata.raised || false, toggleRaisedHand }
|
||||
return { isHandRaised: parsedMetadata.raised, toggleRaisedHand }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useParams } from 'wouter'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
|
||||
export const useRoomData = (): ApiRoom | undefined => {
|
||||
const room = useRoomContext()
|
||||
const { roomId } = useParams()
|
||||
const queryKey = [keys.room, roomId]
|
||||
const queryKey = [keys.room, roomId, room.localParticipant.name]
|
||||
return queryClient.getQueryData<ApiRoom>(queryKey)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
|
||||
import { MobileControlBar } from './MobileControlBar'
|
||||
import { DesktopControlBar } from './DesktopControlBar'
|
||||
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
|
||||
@@ -8,8 +8,10 @@ import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
@@ -19,11 +21,9 @@ export function DesktopControlBar({
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
@@ -87,7 +87,21 @@ export function DesktopControlBar({
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</div>
|
||||
<MoreOptions parentElement={desktopControlBarEl} />
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingRight: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<ChatToggle />
|
||||
<ParticipantsToggle />
|
||||
<TranscriptToggle />
|
||||
<SupportToggle />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||
import { Button } from '@/primitives'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONTROL_BAR_BREAKPOINT = 750
|
||||
|
||||
const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
|
||||
<>
|
||||
<ChatToggle onPress={onPress} />
|
||||
<ParticipantsToggle onPress={onPress} />
|
||||
<TranscriptToggle onPress={onPress} />
|
||||
<SupportToggle onPress={onPress} />
|
||||
</>
|
||||
)
|
||||
|
||||
export const LateralMenu = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const handlePress = () => setIsOpen(!isOpen)
|
||||
const handleClose = () => setIsOpen(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
square
|
||||
variant="secondaryDark"
|
||||
aria-label={t('controls.moreOptions')}
|
||||
tooltip={t('controls.moreOptions')}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isOpen ? <RiArrowDownSLine /> : <RiArrowUpSLine />}
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog
|
||||
className={css({
|
||||
width: '65px',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
paddingTop: '10px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<NavigationControls onPress={handleClose} />
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const MoreOptions = ({
|
||||
parentElement,
|
||||
}: {
|
||||
parentElement: RefObject<HTMLDivElement>
|
||||
}) => {
|
||||
const { width: parentWidth } = useSize(parentElement)
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingRight: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{parentWidth > CONTROL_BAR_BREAKPOINT ? (
|
||||
<NavigationControls />
|
||||
) : (
|
||||
<LateralMenu />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
usePersistentUserChoices,
|
||||
type LocalUserChoices as LocalUserChoicesLK,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { useParams } from 'wouter'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
@@ -9,11 +9,6 @@ import { useUser, UserAware } from '@/features/auth'
|
||||
import { Conference } from '../components/Conference'
|
||||
import { Join } from '../components/Join'
|
||||
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
||||
import { ProcessorSerialized } from '../livekit/components/blur'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
processorSerialized?: ProcessorSerialized
|
||||
}
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { authUrl, useUser } from '../auth'
|
||||
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
}
|
||||
|
||||
export class SdkReverseClient {
|
||||
/**
|
||||
* IDEA: Use API Key. Must be based on some sort of credentials? No needs for now as there are no security
|
||||
* plausible at the moment.
|
||||
*/
|
||||
static getAllowTargetOrigin() {
|
||||
return '*'
|
||||
}
|
||||
|
||||
static post(type: ClientMessageType, data: unknown = {}) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type,
|
||||
data,
|
||||
},
|
||||
SdkReverseClient.getAllowTargetOrigin()
|
||||
)
|
||||
}
|
||||
|
||||
static broadcastAuthentication() {
|
||||
const bc = new BroadcastChannel('APP_CHANNEL')
|
||||
bc.postMessage({ type: 'AUTHENTICATED' })
|
||||
|
||||
/**
|
||||
* This means the parent window has authenticated has successfully refetched user, then we can close the popup.
|
||||
*/
|
||||
bc.onmessage = (event) => {
|
||||
if (event.data.type === 'AUTHENTICATED_ACK') {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static waitForAuthenticationAck() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const bc = new BroadcastChannel('APP_CHANNEL')
|
||||
bc.onmessage = async (event) => {
|
||||
if (event.data.type === 'AUTHENTICATED') {
|
||||
resolve()
|
||||
bc.postMessage({ type: 'AUTHENTICATED_ACK' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function to be awaited in order to make sure the user is logged in.
|
||||
* If not logged-in it opens a popup with the connection flow, the promise returned is resolved
|
||||
* once logged-in.
|
||||
*
|
||||
* To be used in SDK scope.
|
||||
*/
|
||||
export function useEnsureAuth() {
|
||||
const { isLoggedIn, ...other } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
|
||||
const startSSO = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
SdkReverseClient.waitForAuthenticationAck().then(async () => {
|
||||
await other.refetch()
|
||||
resolve()
|
||||
})
|
||||
const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,
|
||||
width=400,height=900,left=100,top=100`
|
||||
window.open(new URL('authenticate/', authUrl()).href, '', params)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
if (!isLoggedIn) {
|
||||
await startSSO()
|
||||
}
|
||||
}
|
||||
|
||||
return { ensureAuth }
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Button } from '@/primitives/Button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useState } from 'react'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { VisioIcon } from '@/assets/VisioIcon'
|
||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
import {
|
||||
ClientMessageType,
|
||||
SdkReverseClient,
|
||||
useEnsureAuth,
|
||||
} from '../SdkReverseClient'
|
||||
|
||||
export const SdkCreateButton = () => {
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createButton' })
|
||||
const [roomUrl, setRoomUrl] = useState<string>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const { ensureAuth } = useEnsureAuth()
|
||||
|
||||
const submitCreateRoom = async () => {
|
||||
setIsLoading(true)
|
||||
const slug = generateRoomId()
|
||||
const data = await createRoom({ slug, username })
|
||||
const roomUrlTmp = getRouteUrl('room', data.slug)
|
||||
setRoomUrl(roomUrlTmp)
|
||||
setIsLoading(false)
|
||||
SdkReverseClient.post(ClientMessageType.ROOM_CREATED, {
|
||||
url: roomUrlTmp,
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
await ensureAuth()
|
||||
submitCreateRoom()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
paddingTop: '3px',
|
||||
paddingLeft: '3px',
|
||||
})}
|
||||
>
|
||||
{roomUrl ? (
|
||||
<RoomUrl roomUrl={roomUrl} />
|
||||
) : (
|
||||
<Button
|
||||
variant="primaryDark"
|
||||
aria-label={t('label')}
|
||||
onPress={submit}
|
||||
data-attr="sdk-create"
|
||||
loading={isLoading}
|
||||
icon={<VisioIcon />}
|
||||
>
|
||||
{t('label')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RoomUrl = ({ roomUrl }: { roomUrl: string }) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(roomUrl!)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
color: 'greyscale.600',
|
||||
})}
|
||||
>
|
||||
{roomUrl}
|
||||
</span>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'quaternaryText'}
|
||||
data-attr="sdk-create-copy"
|
||||
onPress={copy}
|
||||
square
|
||||
>
|
||||
{isCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
usePersistentUserChoices,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useState } from 'react'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
|
||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const initializeSupportSession = (user: ApiUser) => {
|
||||
if (!Crisp.isCrispInjected()) return
|
||||
@@ -30,18 +29,3 @@ export const useSupport = ({ id }: useSupportProps) => {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
|
||||
// So we need to safely check if Crisp is available and not blocked
|
||||
const isCrispAvailable = () => {
|
||||
try {
|
||||
return !!window?.$crisp?.is
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const useIsSupportEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
return !!data?.support?.id && isCrispAvailable()
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* If value stays truthy for more than waitFor ms, syncValue takes the value of value.
|
||||
* @param value
|
||||
* @param waitFor
|
||||
* @returns
|
||||
*/
|
||||
export function useSyncAfterDelay<T>(value: T, waitFor: number = 300) {
|
||||
const valueRef = useRef(value)
|
||||
const timeoutRef = useRef<NodeJS.Timeout>()
|
||||
const [syncValue, setSyncValue] = useState<T>()
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value
|
||||
if (value) {
|
||||
if (!timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setSyncValue(valueRef.current)
|
||||
timeoutRef.current = undefined
|
||||
}, waitFor)
|
||||
}
|
||||
} else {
|
||||
setSyncValue(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
return syncValue
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export const Footer = () => {
|
||||
{t('links.data')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
@@ -255,17 +255,6 @@ export const Footer = () => {
|
||||
{t('links.accessibility')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://github.com/numerique-gouv/meet/"
|
||||
aria-label={t('links.code') + ' - ' + t('links.ariaLabel')}
|
||||
>
|
||||
{t('links.code')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
</SecondRow>
|
||||
<ThirdRow>
|
||||
{t('mentions')}{' '}
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"legalsTerms": "",
|
||||
"data": "",
|
||||
"accessibility": "",
|
||||
"ariaLabel": "",
|
||||
"code": ""
|
||||
"ariaLabel": ""
|
||||
},
|
||||
"mentions": "",
|
||||
"license": ""
|
||||
|
||||
@@ -6,11 +6,5 @@
|
||||
"raised": {
|
||||
"description": "",
|
||||
"cta": ""
|
||||
},
|
||||
"muted": "",
|
||||
"openChat": "",
|
||||
"lowerHand": {
|
||||
"auto": "",
|
||||
"dismiss": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
"label": ""
|
||||
},
|
||||
"heading": "",
|
||||
"effects": {
|
||||
"description": "",
|
||||
"title": "",
|
||||
"subTitle": ""
|
||||
},
|
||||
"joinLabel": "",
|
||||
"joinMeeting": "",
|
||||
"toggleOff": "",
|
||||
@@ -32,9 +27,7 @@
|
||||
"usernameLabel": "",
|
||||
"errors": {
|
||||
"usernameEmpty": ""
|
||||
},
|
||||
"cameraDisabled": "",
|
||||
"cameraStarting": ""
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
@@ -85,8 +78,7 @@
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"support": "",
|
||||
"moreOptions": ""
|
||||
"support": ""
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "",
|
||||
@@ -95,11 +87,7 @@
|
||||
"settings": "",
|
||||
"username": "",
|
||||
"effects": "",
|
||||
"switchCamera": "",
|
||||
"fullscreen": {
|
||||
"enter": "",
|
||||
"exit": ""
|
||||
}
|
||||
"switchCamera": ""
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
@@ -107,17 +95,11 @@
|
||||
"notAvailable": "",
|
||||
"heading": "",
|
||||
"blur": {
|
||||
"title": "",
|
||||
"light": "",
|
||||
"normal": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
},
|
||||
"virtual": {
|
||||
"title": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
},
|
||||
"experimental": ""
|
||||
},
|
||||
"sidePanel": {
|
||||
@@ -179,7 +161,6 @@
|
||||
"muteYourself": "",
|
||||
"muteParticipant": "",
|
||||
"muteParticipantAlert": {
|
||||
"heading": "",
|
||||
"description": "",
|
||||
"confirm": "",
|
||||
"cancel": ""
|
||||
@@ -190,19 +171,5 @@
|
||||
},
|
||||
"recording": {
|
||||
"label": ""
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
"enable": "",
|
||||
"disable": ""
|
||||
},
|
||||
"effects": "",
|
||||
"muteParticipant": "",
|
||||
"fullScreen": ""
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "",
|
||||
"stop": "",
|
||||
"ignore": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,7 @@
|
||||
"legalsTerms": "Legal Notice",
|
||||
"data": "Personal Data and Cookies",
|
||||
"accessibility": "Accessibility: audit in progress",
|
||||
"ariaLabel": "new window",
|
||||
"code": "Open Source Code Repository"
|
||||
"ariaLabel": "new window"
|
||||
},
|
||||
"mentions": "Unless otherwise stated, the contents of this site are available under",
|
||||
"license": "etalab 2.0 license"
|
||||
|
||||
@@ -6,11 +6,5 @@
|
||||
"raised": {
|
||||
"description": "{{name}} has raised their hand.",
|
||||
"cta": "Open waiting list"
|
||||
},
|
||||
"muted": "{{name}} has muted your microphone. No participant can hear you.",
|
||||
"openChat": "Open chat",
|
||||
"lowerHand": {
|
||||
"auto": "It seems you have started speaking, so your hand will be lowered.",
|
||||
"dismiss": "Keep hand raised"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"effects": {
|
||||
"description": "Apply effects",
|
||||
"title": "Effects",
|
||||
"subTitle": "Configure your camera's effects."
|
||||
},
|
||||
"heading": "Join the meeting",
|
||||
"joinLabel": "Join",
|
||||
"joinMeeting": "Join meeting",
|
||||
@@ -32,9 +27,7 @@
|
||||
"usernameLabel": "Your name",
|
||||
"errors": {
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"cameraStarting": "Camera is starting."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -84,8 +77,7 @@
|
||||
"open": "Hide AI assistant",
|
||||
"closed": "Show AI assistant"
|
||||
},
|
||||
"support": "Support",
|
||||
"moreOptions": "More options"
|
||||
"support": "Support"
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "More Options",
|
||||
@@ -94,29 +86,19 @@
|
||||
"settings": "Settings",
|
||||
"username": "Update Your Name",
|
||||
"effects": "Apply effects",
|
||||
"switchCamera": "Switch camera",
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
"exit": "Exit fullscreen mode"
|
||||
}
|
||||
"switchCamera": "Switch camera"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
|
||||
"notAvailable": "Video effects will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
|
||||
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
|
||||
"heading": "Blur",
|
||||
"blur": {
|
||||
"title": "Background blur",
|
||||
"light": "Light blur",
|
||||
"normal": "Blur",
|
||||
"apply": "Enable blur",
|
||||
"clear": "Disable blur"
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtual background",
|
||||
"apply": "Enable virtual background",
|
||||
"clear": "Disable virtual background"
|
||||
},
|
||||
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
|
||||
},
|
||||
"sidePanel": {
|
||||
@@ -178,7 +160,6 @@
|
||||
"muteYourself": "Close your mic",
|
||||
"muteParticipant": "Close the mic of {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
"heading": "Mute {{name}}",
|
||||
"description": "Mute {{name}} for all participants? {{name}} will be the only one who can unmute themselves.",
|
||||
"confirm": "Mute",
|
||||
"cancel": "Cancel"
|
||||
@@ -189,19 +170,5 @@
|
||||
},
|
||||
"recording": {
|
||||
"label": "Recording"
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
},
|
||||
"effects": "Apply visual effects",
|
||||
"muteParticipant": "Mute {{name}}",
|
||||
"fullScreen": "Full screen"
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "To avoid infinite loop display, do not share your entire screen or browser window. Instead, share a tab or another window.",
|
||||
"stop": "Stop presenting",
|
||||
"ignore": "Ignore"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Create a Visio link"
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,7 @@
|
||||
"legalsTerms": "Mentions légales",
|
||||
"data": "Données personnelles et cookie",
|
||||
"accessibility": "Accessibilités : audit en cours",
|
||||
"ariaLabel": "nouvelle fenêtre",
|
||||
"code": "Dépôt de code Open Source"
|
||||
"ariaLabel": "nouvelle fenêtre"
|
||||
},
|
||||
"mentions": "Sauf mention contraire, les contenus de ce site sont disponibles sous",
|
||||
"license": "licence etalab 2.0"
|
||||
|
||||
@@ -6,11 +6,5 @@
|
||||
"raised": {
|
||||
"description": "{{name}} a levé la main.",
|
||||
"cta": "Ouvrir la file d'attente"
|
||||
},
|
||||
"muted": "{{name}} a coupé votre micro. Aucun participant ne peut l'entendre.",
|
||||
"openChat": "Ouvrir le chat",
|
||||
"lowerHand": {
|
||||
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
|
||||
"dismiss": "Laisser la main levée"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Rejoindre la réunion",
|
||||
"effects": {
|
||||
"description": "Appliquer des effets",
|
||||
"title": "Effets",
|
||||
"subTitle": "Paramétrez les effets de votre caméra."
|
||||
},
|
||||
"joinLabel": "Rejoindre",
|
||||
"joinMeeting": "Rejoindre la réjoindre",
|
||||
"toggleOff": "Cliquez pour désactiver",
|
||||
@@ -32,9 +27,7 @@
|
||||
"usernameLabel": "Votre nom",
|
||||
"errors": {
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
"cameraStarting": "La caméra va démarrer."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
@@ -84,8 +77,7 @@
|
||||
"open": "Masquer l'assistant IA",
|
||||
"closed": "Afficher l'assistant IA"
|
||||
},
|
||||
"support": "Support",
|
||||
"moreOptions": "Plus d'options"
|
||||
"support": "Support"
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "Plus d'options",
|
||||
@@ -94,29 +86,19 @@
|
||||
"settings": "Paramètres",
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Appliquer des effets",
|
||||
"switchCamera": "Changer de caméra",
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
"exit": "Quitter le mode plein écran"
|
||||
}
|
||||
"switchCamera": "Changer de caméra"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
|
||||
"notAvailable": "Les effets vidéo seront bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
|
||||
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
|
||||
"heading": "Flou",
|
||||
"blur": {
|
||||
"title": "Flou d'arrière-plan",
|
||||
"light": "Léger flou",
|
||||
"normal": "Flou",
|
||||
"apply": "Appliquer le flou",
|
||||
"clear": "Désactiver le flou"
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Arrière-plan virtuel",
|
||||
"apply": "Appliquer l'arrière plan virtuel",
|
||||
"clear": "Désactiver l'arrière plan virtuel"
|
||||
},
|
||||
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
|
||||
},
|
||||
"sidePanel": {
|
||||
@@ -178,7 +160,6 @@
|
||||
"muteYourself": "Couper votre micro",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
"heading": "Couper le micro de {{name}}",
|
||||
"description": "Couper le micro de {{name}} pour tous les participants ? {{name}} est la seule personne habilitée à réactiver son micro",
|
||||
"confirm": "Couper le micro",
|
||||
"cancel": "Annuler"
|
||||
@@ -189,19 +170,5 @@
|
||||
},
|
||||
"recording": {
|
||||
"label": "Enregistrement"
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
},
|
||||
"effects": "Appliquer des effets",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"fullScreen": "Plein écran"
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran ni de votre fenêtre de navigateur. Partagez plutôt un onglet ou une autre fenêtre.",
|
||||
"stop": "Arrêter la présentation",
|
||||
"ignore": "Ignorer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Créer un lien Visio"
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"settingsButtonLabel": "Paramètres",
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications"
|
||||
|
||||
@@ -6,15 +6,12 @@ import { type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||
import { ReactNode } from 'react'
|
||||
import { Loader } from './Loader'
|
||||
|
||||
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
RACButtonsProps &
|
||||
TooltipWrapperProps & {
|
||||
// Use tooltip as description below the button.
|
||||
description?: boolean
|
||||
} & {
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
@@ -23,16 +20,13 @@ export const Button = ({
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
const { className, ...remainingComponentProps } = componentProps
|
||||
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACButton
|
||||
className={[buttonRecipe(variantProps), className].join(' ')}
|
||||
{...(remainingComponentProps as RACButtonsProps)}
|
||||
className={buttonRecipe(variantProps)}
|
||||
{...(componentProps as RACButtonsProps)}
|
||||
>
|
||||
{!props.loading && props.icon}
|
||||
{props.loading && <Loader />}
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</RACButton>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
|
||||
import { text } from './Text'
|
||||
import { MutableRefObject } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
const StyledModalOverlay = styled(ModalOverlay, {
|
||||
base: {
|
||||
@@ -49,24 +48,6 @@ const StyledRACDialog = styled(RACDialog, {
|
||||
},
|
||||
})
|
||||
|
||||
const ModalContent = styled('div', {
|
||||
base: {
|
||||
margin: 'auto',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
full: {
|
||||
width: 'fit-content',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
large: {
|
||||
width: '100%',
|
||||
xl: { width: '1200px' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type DialogProps = RACDialogProps & {
|
||||
title?: string
|
||||
onClose?: () => void
|
||||
@@ -82,7 +63,6 @@ export type DialogProps = RACDialogProps & {
|
||||
onOpenChange?: (isOpen: boolean) => void
|
||||
type?: 'flex'
|
||||
innerRef?: MutableRefObject<HTMLDivElement | null>
|
||||
size?: 'full' | 'large'
|
||||
}
|
||||
|
||||
export const Dialog = ({
|
||||
@@ -92,7 +72,6 @@ export const Dialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
innerRef,
|
||||
size = 'full',
|
||||
...dialogProps
|
||||
}: DialogProps) => {
|
||||
const isAlert = dialogProps['role'] === 'alertdialog'
|
||||
@@ -115,16 +94,9 @@ export const Dialog = ({
|
||||
<StyledRACDialog {...dialogProps}>
|
||||
{({ close }) => (
|
||||
<VerticallyOffCenter>
|
||||
<ModalContent size={size}>
|
||||
<Div margin="auto" width="fit-content" maxWidth="full">
|
||||
<Div margin="1rem" pointerEvents="auto">
|
||||
<Box
|
||||
size="sm"
|
||||
type={boxType}
|
||||
ref={innerRef}
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<Box size="sm" type={boxType} ref={innerRef}>
|
||||
{!!title && (
|
||||
<Heading
|
||||
slot="title"
|
||||
@@ -152,7 +124,7 @@ export const Dialog = ({
|
||||
)}
|
||||
</Box>
|
||||
</Div>
|
||||
</ModalContent>
|
||||
</Div>
|
||||
</VerticallyOffCenter>
|
||||
)}
|
||||
</StyledRACDialog>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, useCloseDialog } from '@/primitives'
|
||||
import { ButtonProps } from '@/primitives/Button'
|
||||
|
||||
/**
|
||||
* From wrapper that exposes form data on submit and adds submit/cancel buttons
|
||||
@@ -14,7 +13,6 @@ import { ButtonProps } from '@/primitives/Button'
|
||||
export const Form = ({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
submitButtonProps,
|
||||
withCancelButton = true,
|
||||
onCancelButtonPress,
|
||||
children,
|
||||
@@ -27,7 +25,6 @@ export const Form = ({
|
||||
event: FormEvent<HTMLFormElement>
|
||||
) => void
|
||||
submitLabel: string
|
||||
submitButtonProps?: ButtonProps
|
||||
withCancelButton?: boolean
|
||||
onCancelButtonPress?: () => void
|
||||
}) => {
|
||||
@@ -50,7 +47,7 @@ export const Form = ({
|
||||
>
|
||||
{children}
|
||||
<HStack gap="gutter">
|
||||
<Button type="submit" variant="primary" {...submitButtonProps}>
|
||||
<Button type="submit" variant="primary">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
{!!onCancel && (
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Text, TextProps } from './Text'
|
||||
import { Text } from './Text'
|
||||
|
||||
export const H = ({
|
||||
children,
|
||||
lvl,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement> & {
|
||||
lvl: 1 | 2 | 3
|
||||
} & TextProps) => {
|
||||
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
|
||||
const tag = `h${lvl}` as const
|
||||
return (
|
||||
<Text as={tag} variant={tag} {...props}>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { cva } from '@/styled-system/css'
|
||||
|
||||
const loader = cva({
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
animation: 'rotate 1s linear infinite',
|
||||
'&:before, &:after': {
|
||||
content: '""',
|
||||
boxSizing: 'border-box',
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '50%',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'white',
|
||||
},
|
||||
_before: {
|
||||
animation: 'prixClipFix 2s linear infinite',
|
||||
},
|
||||
_after: {
|
||||
borderColor: 'white',
|
||||
animation:
|
||||
'prixClipFix 2s linear infinite, rotate 0.5s linear infinite reverse',
|
||||
inset: 6,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
small: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
'&:before, &:after': {
|
||||
borderWidth: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'small',
|
||||
},
|
||||
})
|
||||
|
||||
export const Loader = () => {
|
||||
return <div className={loader()}></div>
|
||||
}
|
||||
@@ -28,14 +28,6 @@ export const text = cva({
|
||||
paddingTop: 'heading',
|
||||
},
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: '1rem',
|
||||
color: 'greyscale.600',
|
||||
},
|
||||
bodyXsBold: {
|
||||
textStyle: 'body',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
body: {
|
||||
textStyle: 'body',
|
||||
},
|
||||
@@ -92,11 +84,6 @@ export const text = cva({
|
||||
marginBottom: 0.5,
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
true: {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
last: {
|
||||
true: {
|
||||
marginBottom: '0!',
|
||||
|
||||