mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9dedb91b | |||
| 3da3641a19 | |||
| 21626b4c4c | |||
| f16cedf5a5 | |||
| 00e4679dc5 | |||
| f76e7f0e51 | |||
| b9ffbd179c | |||
| a3ef1d4a26 | |||
| d11bcc5de9 | |||
| 412914cf01 | |||
| d511aedd39 | |||
| 7edf7d194b | |||
| 6324608a4a | |||
| a402c2f46f | |||
| e21858febe | |||
| 3d615fa582 | |||
| 4293444d3e | |||
| a55a4d5b5d | |||
| dd9a87a33b | |||
| 24242ef01a | |||
| b537cdfd93 | |||
| 80cc7c723f | |||
| e9210213b1 | |||
| 634f1924be | |||
| 4e175a8361 | |||
| c5ce32ef79 | |||
| aaf6b03a25 | |||
| 66bc739411 | |||
| 1b48fa256e | |||
| 053c4a40e9 | |||
| 53d732d802 | |||
| 26ca81db40 | |||
| e6e6a3bde7 | |||
| 26fdaac589 | |||
| 1fd1cb71ba | |||
| c8023573c6 | |||
| 584be7e65b | |||
| 59ec88e84a | |||
| 1b2e0ad431 | |||
| 20464a2845 | |||
| ece6284de2 | |||
| 82e994e5b1 | |||
| 85aa7a7251 | |||
| c4ececd03a | |||
| 57bba04cf3 | |||
| 11e162dbd4 | |||
| e06e9d1496 | |||
| c8cc9909ba | |||
| c218a1f7a2 | |||
| 490aaba30a | |||
| 790d37569c | |||
| 1e140a01b5 | |||
| 4ecb7202ec | |||
| 4b7419fe4a | |||
| 44d3c738d7 | |||
| 888c66a218 | |||
| 86641bd160 | |||
| 3d91af23cc | |||
| 2d0d30ce01 | |||
| 98ae9af84a | |||
| 3dda12ada6 | |||
| 8b2750c413 | |||
| c16c27fa50 | |||
| b3ef57e1b6 | |||
| b554a6a542 | |||
| 574fd6dc89 | |||
| 8d77332a0a | |||
| 0e2c805b41 | |||
| 9782eb8c7c | |||
| f6bc57ba91 | |||
| 4d5aec9a49 | |||
| 74b296aa37 | |||
| 88fadd1d61 | |||
| cac58f49d3 | |||
| 12c27eedac | |||
| 6a7ec95493 | |||
| e41656a760 | |||
| eb90c0f28c | |||
| f3c4b0ac40 | |||
| 038e6368e4 | |||
| 028f20375f | |||
| ccc23ff46a | |||
| e3b7a1f77b | |||
| 69a8eea1ce | |||
| 20493edd07 | |||
| 79f7fcab6e | |||
| ce3a6dab12 | |||
| 1b57dd0ecf | |||
| c6318910c2 | |||
| 03b3630611 | |||
| 5b8c8d493a | |||
| b12fb6bf48 | |||
| a8f64f5a36 | |||
| 6ab5b3300a | |||
| 6189e6454d | |||
| a8b2c56f4b | |||
| b472220151 |
+1
-1
Submodule secrets updated: 9da011f5c2...f5fbc16e6e
@@ -2,6 +2,11 @@
|
|||||||
Utils functions used in the core app
|
Utils functions used in the core app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa:S311
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import random
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -10,6 +15,28 @@ from django.conf import settings
|
|||||||
from livekit.api import AccessToken, VideoGrants
|
from livekit.api import AccessToken, VideoGrants
|
||||||
|
|
||||||
|
|
||||||
|
def generate_color(identity: str) -> str:
|
||||||
|
"""Generates a consistent HSL color based on a given identity string.
|
||||||
|
|
||||||
|
The function seeds the random generator with the identity's hash,
|
||||||
|
ensuring consistent color output. The HSL format allows fine-tuned control
|
||||||
|
over saturation and lightness, empirically adjusted to produce visually
|
||||||
|
appealing and distinct colors. HSL is preferred over hex to constrain the color
|
||||||
|
range and ensure predictability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa:S324
|
||||||
|
identity_hash = hashlib.sha1(identity.encode("utf-8"))
|
||||||
|
# Keep only hash's last 16 bits, collisions are not a concern
|
||||||
|
seed = int(identity_hash.hexdigest(), 16) & 0xFFFF
|
||||||
|
random.seed(seed)
|
||||||
|
hue = random.randint(0, 360)
|
||||||
|
saturation = random.randint(50, 75)
|
||||||
|
lightness = random.randint(25, 60)
|
||||||
|
|
||||||
|
return f"hsl({hue}, {saturation}%, {lightness}%)"
|
||||||
|
|
||||||
|
|
||||||
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -25,6 +52,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
|||||||
video_grants = VideoGrants(
|
video_grants = VideoGrants(
|
||||||
room=room,
|
room=room,
|
||||||
room_join=True,
|
room_join=True,
|
||||||
|
room_admin=True,
|
||||||
|
room_record=True,
|
||||||
can_update_own_metadata=True,
|
can_update_own_metadata=True,
|
||||||
can_publish_sources=[
|
can_publish_sources=[
|
||||||
"camera",
|
"camera",
|
||||||
@@ -49,6 +78,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
|||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(username or default_username)
|
||||||
|
.with_metadata(json.dumps({"color": generate_color(identity)}))
|
||||||
)
|
)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ class Base(Configuration):
|
|||||||
# Easy thumbnails
|
# Easy thumbnails
|
||||||
THUMBNAIL_EXTENSION = "webp"
|
THUMBNAIL_EXTENSION = "webp"
|
||||||
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
||||||
|
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
||||||
THUMBNAIL_ALIASES = {}
|
THUMBNAIL_ALIASES = {}
|
||||||
|
|
||||||
# Celery
|
# Celery
|
||||||
|
|||||||
+11
-11
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.34.154",
|
"boto3==1.35.10",
|
||||||
"Brotli==1.1.0",
|
"Brotli==1.1.0",
|
||||||
"celery[redis]==5.4.0",
|
"celery[redis]==5.4.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
@@ -36,28 +36,28 @@ dependencies = [
|
|||||||
"django-redis==5.4.0",
|
"django-redis==5.4.0",
|
||||||
"django-storages[s3]==1.14.4",
|
"django-storages[s3]==1.14.4",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django==5.0.7",
|
"django==5.1",
|
||||||
"djangorestframework==3.15.2",
|
"djangorestframework==3.15.2",
|
||||||
"drf_spectacular==0.27.2",
|
"drf_spectacular==0.27.2",
|
||||||
"dockerflow==2024.4.2",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.9",
|
"easy_thumbnails==2.9",
|
||||||
"factory_boy==3.3.0",
|
"factory_boy==3.3.1",
|
||||||
"freezegun==1.5.1",
|
"freezegun==1.5.1",
|
||||||
"gunicorn==22.0.0",
|
"gunicorn==23.0.0",
|
||||||
"jsonschema==4.23.0",
|
"jsonschema==4.23.0",
|
||||||
"june-analytics-python==2.3.0",
|
"june-analytics-python==2.3.0",
|
||||||
"markdown==3.6",
|
"markdown==3.7",
|
||||||
"nested-multipart-parser==1.5.0",
|
"nested-multipart-parser==1.5.0",
|
||||||
"psycopg[binary]==3.2.1",
|
"psycopg[binary]==3.2.1",
|
||||||
"PyJWT==2.9.0",
|
"PyJWT==2.9.0",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"requests==2.32.3",
|
"requests==2.32.3",
|
||||||
"sentry-sdk==2.12.0",
|
"sentry-sdk==2.13.0",
|
||||||
"url-normalize==1.4.3",
|
"url-normalize==1.4.3",
|
||||||
"WeasyPrint>=60.2",
|
"WeasyPrint>=60.2",
|
||||||
"whitenoise==6.7.0",
|
"whitenoise==6.7.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.6.0",
|
"livekit-api==0.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -71,17 +71,17 @@ dev = [
|
|||||||
"django-extensions==3.2.3",
|
"django-extensions==3.2.3",
|
||||||
"drf-spectacular-sidecar==2024.7.1",
|
"drf-spectacular-sidecar==2024.7.1",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.26.0",
|
"ipython==8.27.0",
|
||||||
"pyfakefs==5.6.0",
|
"pyfakefs==5.6.0",
|
||||||
"pylint-django==2.5.5",
|
"pylint-django==2.5.5",
|
||||||
"pylint==3.2.6",
|
"pylint==3.2.7",
|
||||||
"pytest-cov==5.0.0",
|
"pytest-cov==5.0.0",
|
||||||
"pytest-django==4.8.0",
|
"pytest-django==4.8.0",
|
||||||
"pytest==8.3.2",
|
"pytest==8.3.2",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.6.1",
|
"pytest-xdist==3.6.1",
|
||||||
"responses==0.25.3",
|
"responses==0.25.3",
|
||||||
"ruff==0.5.6",
|
"ruff==0.6.3",
|
||||||
"types-requests==2.32.0.20240712",
|
"types-requests==2.32.0.20240712",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+107
-46
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.3.3",
|
"@livekit/components-react": "2.3.3",
|
||||||
"@livekit/components-styles": "1.0.12",
|
"@livekit/components-styles": "1.0.12",
|
||||||
"@pandacss/preset-panda": "0.41.0",
|
"@pandacss/preset-panda": "0.41.0",
|
||||||
|
"@react-aria/toast": "3.0.0-beta.15",
|
||||||
"@remixicon/react": "4.2.0",
|
"@remixicon/react": "4.2.0",
|
||||||
"@tanstack/react-query": "5.49.2",
|
"@tanstack/react-query": "5.49.2",
|
||||||
"hoofd": "1.7.1",
|
"hoofd": "1.7.1",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react-aria-components": "1.2.1",
|
"react-aria-components": "1.2.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-i18next": "14.1.3",
|
"react-i18next": "14.1.3",
|
||||||
|
"use-sound": "^4.0.3",
|
||||||
"valtio": "1.13.2",
|
"valtio": "1.13.2",
|
||||||
"wouter": "3.3.0"
|
"wouter": "3.3.0"
|
||||||
},
|
},
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
"@typescript-eslint/parser": "7.13.1",
|
"@typescript-eslint/parser": "7.13.1",
|
||||||
"@vitejs/plugin-react": "4.3.1",
|
"@vitejs/plugin-react": "4.3.1",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.9.0",
|
"eslint-plugin-jsx-a11y": "6.9.0",
|
||||||
"eslint-plugin-react-hooks": "4.6.2",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-refresh": "0.4.7",
|
"eslint-plugin-react-refresh": "0.4.7",
|
||||||
@@ -454,7 +456,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1108,9 +1109,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@internationalized/date": {
|
"node_modules/@internationalized/date": {
|
||||||
"version": "3.5.4",
|
"version": "3.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz",
|
||||||
"integrity": "sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==",
|
"integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -1972,35 +1973,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/i18n": {
|
"node_modules/@react-aria/i18n": {
|
||||||
"version": "3.11.1",
|
"version": "3.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.2.tgz",
|
||||||
"integrity": "sha512-vuiBHw1kZruNMYeKkTGGnmPyMnM5T+gT8bz97H1FqIq1hQ6OPzmtBZ6W6l6OIMjeHI5oJo4utTwfZl495GALFQ==",
|
"integrity": "sha512-PvEyC6JWylTpe8dQEWqQwV6GiA+pbTxHQd//BxtMSapRW3JT9obObAnb/nFhj3HthkUvqHyj0oO1bfeN+mtD8A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.5.4",
|
"@internationalized/date": "^3.5.5",
|
||||||
"@internationalized/message": "^3.1.4",
|
"@internationalized/message": "^3.1.4",
|
||||||
"@internationalized/number": "^3.5.3",
|
"@internationalized/number": "^3.5.3",
|
||||||
"@internationalized/string": "^3.2.3",
|
"@internationalized/string": "^3.2.3",
|
||||||
"@react-aria/ssr": "^3.9.4",
|
"@react-aria/ssr": "^3.9.5",
|
||||||
"@react-aria/utils": "^3.24.1",
|
"@react-aria/utils": "^3.25.2",
|
||||||
"@react-types/shared": "^3.23.1",
|
"@react-types/shared": "^3.24.1",
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/interactions": {
|
"node_modules/@react-aria/interactions": {
|
||||||
"version": "3.21.3",
|
"version": "3.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.22.2.tgz",
|
||||||
"integrity": "sha512-BWIuf4qCs5FreDJ9AguawLVS0lV9UU+sK4CCnbCNNmYqOWY+1+gRXCsnOM32K+oMESBxilAjdHW5n1hsMqYMpA==",
|
"integrity": "sha512-xE/77fRVSlqHp2sfkrMeNLrqf2amF/RyuAS6T5oDJemRSgYM3UoxTbWjucPhfnoW7r32pFPHHgz4lbdX8xqD/g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-aria/ssr": "^3.9.4",
|
"@react-aria/ssr": "^3.9.5",
|
||||||
"@react-aria/utils": "^3.24.1",
|
"@react-aria/utils": "^3.25.2",
|
||||||
"@react-types/shared": "^3.23.1",
|
"@react-types/shared": "^3.24.1",
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/label": {
|
"node_modules/@react-aria/label": {
|
||||||
@@ -2016,6 +2017,20 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-aria/landmark": {
|
||||||
|
"version": "3.0.0-beta.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.0-beta.15.tgz",
|
||||||
|
"integrity": "sha512-EEABy0IFzqoS7r11HoD2YwiGR5LFw4kWDFTFUFwJkRP5tHEzsrEgkKPSPJXScQr5m7tenV6j0/Zzl1+w1ki3lA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-aria/utils": "^3.25.2",
|
||||||
|
"@react-types/shared": "^3.24.1",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"use-sync-external-store": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-aria/link": {
|
"node_modules/@react-aria/link": {
|
||||||
"version": "3.7.1",
|
"version": "3.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.7.1.tgz",
|
||||||
@@ -2289,9 +2304,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/ssr": {
|
"node_modules/@react-aria/ssr": {
|
||||||
"version": "3.9.4",
|
"version": "3.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz",
|
||||||
"integrity": "sha512-4jmAigVq409qcJvQyuorsmBR4+9r3+JEC60wC+Y0MZV0HCtTmm8D9guYXlJMdx0SSkgj0hHAyFm/HvPNFofCoQ==",
|
"integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
@@ -2299,7 +2314,7 @@
|
|||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/switch": {
|
"node_modules/@react-aria/switch": {
|
||||||
@@ -2402,6 +2417,24 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-aria/toast": {
|
||||||
|
"version": "3.0.0-beta.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.0-beta.15.tgz",
|
||||||
|
"integrity": "sha512-bg6ZXq4B5JYVt3GXVlf075tQOakcfumbDLnUMaijez4BhacuxF01+IvBPzAVEsARVNXfELoXa7Frb2K54Wgvtw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-aria/i18n": "^3.12.2",
|
||||||
|
"@react-aria/interactions": "^3.22.2",
|
||||||
|
"@react-aria/landmark": "3.0.0-beta.15",
|
||||||
|
"@react-aria/utils": "^3.25.2",
|
||||||
|
"@react-stately/toast": "3.0.0-beta.5",
|
||||||
|
"@react-types/button": "^3.9.6",
|
||||||
|
"@react-types/shared": "^3.24.1",
|
||||||
|
"@swc/helpers": "^0.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-aria/toggle": {
|
"node_modules/@react-aria/toggle": {
|
||||||
"version": "3.10.4",
|
"version": "3.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.10.4.tgz",
|
||||||
@@ -2470,18 +2503,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/utils": {
|
"node_modules/@react-aria/utils": {
|
||||||
"version": "3.24.1",
|
"version": "3.25.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.25.2.tgz",
|
||||||
"integrity": "sha512-O3s9qhPMd6n42x9sKeJ3lhu5V1Tlnzhu6Yk8QOvDuXf7UGuUjXf9mzfHJt1dYzID4l9Fwm8toczBzPM9t0jc8Q==",
|
"integrity": "sha512-GdIvG8GBJJZygB4L2QJP1Gabyn2mjFsha73I2wSe+o4DYeGWoJiMZRM06PyTIxLH4S7Sn7eVDtsSBfkc2VY/NA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-aria/ssr": "^3.9.4",
|
"@react-aria/ssr": "^3.9.5",
|
||||||
"@react-stately/utils": "^3.10.1",
|
"@react-stately/utils": "^3.10.3",
|
||||||
"@react-types/shared": "^3.23.1",
|
"@react-types/shared": "^3.24.1",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"clsx": "^2.0.0"
|
"clsx": "^2.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/visually-hidden": {
|
"node_modules/@react-aria/visually-hidden": {
|
||||||
@@ -2827,6 +2860,18 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-stately/toast": {
|
||||||
|
"version": "3.0.0-beta.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.0.0-beta.5.tgz",
|
||||||
|
"integrity": "sha512-MEdQwcKsexlcJ4YQZ9cN5QSIqTlGGdgC5auzSKXXoq15DHuo4mtHfLzXPgcMDYOhOdmyphMto8Vt+21UL2FOrw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"use-sync-external-store": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-stately/toggle": {
|
"node_modules/@react-stately/toggle": {
|
||||||
"version": "3.7.4",
|
"version": "3.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.7.4.tgz",
|
||||||
@@ -2869,14 +2914,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-stately/utils": {
|
"node_modules/@react-stately/utils": {
|
||||||
"version": "3.10.1",
|
"version": "3.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.3.tgz",
|
||||||
"integrity": "sha512-VS/EHRyicef25zDZcM/ClpzYMC5i2YGN6uegOeQawmgfGjb02yaCX0F0zR69Pod9m2Hr3wunTbtpgVXvYbZItg==",
|
"integrity": "sha512-moClv7MlVSHpbYtQIkm0Cx+on8Pgt1XqtPx6fy9rQFb2DNc9u1G3AUVnqA17buOkH1vLxAtX4MedlxMWyRCYYA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-stately/virtualizer": {
|
"node_modules/@react-stately/virtualizer": {
|
||||||
@@ -2905,14 +2950,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/button": {
|
"node_modules/@react-types/button": {
|
||||||
"version": "3.9.4",
|
"version": "3.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.9.6.tgz",
|
||||||
"integrity": "sha512-raeQBJUxBp0axNF74TXB8/H50GY8Q3eV6cEKMbZFP1+Dzr09Ngv0tJBeW0ewAxAguNH5DRoMUAUGIXtSXskVdA==",
|
"integrity": "sha512-8lA+D5JLbNyQikf8M/cPP2cji91aVTcqjrGpDqI7sQnaLFikM8eFR6l1ZWGtZS5MCcbfooko77ha35SYplSQvw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-types/shared": "^3.23.1"
|
"@react-types/shared": "^3.24.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/calendar": {
|
"node_modules/@react-types/calendar": {
|
||||||
@@ -3122,11 +3167,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.23.1",
|
"version": "3.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.24.1.tgz",
|
||||||
"integrity": "sha512-5d+3HbFDxGZjhbMBeFHRQhexMFt4pUce3okyRtUVKbbedQFUrtXSBg9VszgF2RTeQDKDkMCIQDtz5ccP/Lk1gw==",
|
"integrity": "sha512-AUQeGYEm/zDTN6zLzdXolDxz3Jk5dDL7f506F07U8tBwxNNI3WRdhU84G0/AaFikOZzDXhOZDr3MhQMzyE7Ydw==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/slider": {
|
"node_modules/@react-types/slider": {
|
||||||
@@ -6651,6 +6696,11 @@
|
|||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/howler": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
|
||||||
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
@@ -10118,6 +10168,17 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sound": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sound/-/use-sound-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-L205pEUFIrLsGYsCUKHQVCt0ajs//YQOFbEQeNwaWaqQj3y3st4SuR+rvpMHLmv8hgTcfUFlvMQawZNI3OE18w==",
|
||||||
|
"dependencies": {
|
||||||
|
"howler": "^2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@livekit/components-react": "2.3.3",
|
"@livekit/components-react": "2.3.3",
|
||||||
"@livekit/components-styles": "1.0.12",
|
"@livekit/components-styles": "1.0.12",
|
||||||
"@pandacss/preset-panda": "0.41.0",
|
"@pandacss/preset-panda": "0.41.0",
|
||||||
|
"@react-aria/toast": "3.0.0-beta.15",
|
||||||
"@remixicon/react": "4.2.0",
|
"@remixicon/react": "4.2.0",
|
||||||
"@tanstack/react-query": "5.49.2",
|
"@tanstack/react-query": "5.49.2",
|
||||||
"hoofd": "1.7.1",
|
"hoofd": "1.7.1",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"react-aria-components": "1.2.1",
|
"react-aria-components": "1.2.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-i18next": "14.1.3",
|
"react-i18next": "14.1.3",
|
||||||
|
"use-sound": "4.0.3",
|
||||||
"valtio": "1.13.2",
|
"valtio": "1.13.2",
|
||||||
"wouter": "3.3.0"
|
"wouter": "3.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,34 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||||
|
pulse: {
|
||||||
|
'0%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0.7)' },
|
||||||
|
'75%': { boxShadow: '0 0 0 30px rgba(255, 255, 255, 0)' },
|
||||||
|
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
|
||||||
|
},
|
||||||
|
active_speaker: {
|
||||||
|
'0%': { height: '4px' },
|
||||||
|
'25%': { height: '8px' },
|
||||||
|
'50%': { height: '6px' },
|
||||||
|
'100%': { height: '16px' },
|
||||||
|
},
|
||||||
|
active_speake_small: {
|
||||||
|
'0%': { height: '4px' },
|
||||||
|
'25%': { height: '6px' },
|
||||||
|
'50%': { height: '4px' },
|
||||||
|
'100%': { height: '8px' },
|
||||||
|
},
|
||||||
|
wave_hand: {
|
||||||
|
'0%': { transform: 'rotate(0deg)' },
|
||||||
|
'20%': { transform: 'rotate(-20deg)' },
|
||||||
|
'80%': { transform: 'rotate(20deg)' },
|
||||||
|
'100%': { transform: 'rotate(0)' },
|
||||||
|
},
|
||||||
|
pulse_mic: {
|
||||||
|
'0%': { color: 'primary', opacity: '1' },
|
||||||
|
'50%': { color: 'primary', opacity: '0.8' },
|
||||||
|
'100%': { color: 'primary', opacity: '1' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
@@ -199,6 +227,7 @@ const config: Config = {
|
|||||||
text: { value: '{colors.white}' },
|
text: { value: '{colors.white}' },
|
||||||
subtle: { value: '{colors.red.100}' },
|
subtle: { value: '{colors.red.100}' },
|
||||||
'subtle-text': { value: '{colors.red.700}' },
|
'subtle-text': { value: '{colors.red.700}' },
|
||||||
|
...pandaPreset.theme.tokens.colors.red,
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
DEFAULT: { value: '{colors.green.700}' },
|
DEFAULT: { value: '{colors.green.700}' },
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useLang } from 'hoofd'
|
import { useLang } from 'hoofd'
|
||||||
import { Switch, Route } from 'wouter'
|
import { Switch, Route } from 'wouter'
|
||||||
|
import { I18nProvider } from 'react-aria-components'
|
||||||
import { Layout } from './layout/Layout'
|
import { Layout } from './layout/Layout'
|
||||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
@@ -23,15 +24,17 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Layout>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Switch>
|
<Layout>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Switch>
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
))}
|
<Route key={i} path={route.path} component={route.Component} />
|
||||||
<Route component={NotFoundScreen} />
|
))}
|
||||||
</Switch>
|
<Route component={NotFoundScreen} />
|
||||||
</Layout>
|
</Switch>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
</Layout>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</I18nProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const avatar = cva({
|
||||||
|
base: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
borderRadius: '50%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
cursor: 'default',
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
context: {
|
||||||
|
list: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
context: 'list',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
name?: string
|
||||||
|
bgColor?: string
|
||||||
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
|
export const Avatar = ({ name, bgColor, context, ...props }: AvatarProps) => {
|
||||||
|
const initial = name?.trim()?.charAt(0) || ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
}}
|
||||||
|
className={avatar({ context })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||||
|
|
||||||
|
export const SoundTester = () => {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
|
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateActiveId = async (deviceId: string) => {
|
||||||
|
try {
|
||||||
|
await audioRef?.current?.setSinkId(deviceId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error setting sinkId: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateActiveId(activeDeviceId)
|
||||||
|
}, [activeDeviceId])
|
||||||
|
|
||||||
|
// prevent pausing the sound
|
||||||
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
invisible
|
||||||
|
onPress={() => {
|
||||||
|
audioRef?.current?.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
isDisabled={isPlaying}
|
||||||
|
fullWidth
|
||||||
|
style={{
|
||||||
|
color: isPlaying ? 'var(--colors-primary)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? t('audio.speakers.ongoingTest') : t('audio.speakers.test')}
|
||||||
|
</Button>
|
||||||
|
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src="sounds/uprise.mp3"
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
|
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
|
||||||
|
|
||||||
|
// fixme - duplication with the InviteDialog
|
||||||
|
export const LaterMeetingDialog = ({
|
||||||
|
roomId,
|
||||||
|
...dialogProps
|
||||||
|
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
const roomUrl = getRouteUrl('room', roomId)
|
||||||
|
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCopied) {
|
||||||
|
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [isCopied])
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={!!roomId}
|
||||||
|
{...dialogProps}
|
||||||
|
title={t('laterMeetingDialog.heading')}
|
||||||
|
>
|
||||||
|
<P>{t('laterMeetingDialog.description')}</P>
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'primary'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={t('laterMeetingDialog.copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
navigator.clipboard.writeText(roomUrl)
|
||||||
|
setIsCopied(true)
|
||||||
|
}}
|
||||||
|
onHoverChange={setIsHovered}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||||
|
{t('laterMeetingDialog.copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||||
|
/>
|
||||||
|
{isHovered ? (
|
||||||
|
t('laterMeetingDialog.copy')
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
|
textWrap: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<HStack>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#d9e5ff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
|
||||||
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('laterMeetingDialog.permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DialogTrigger } from 'react-aria-components'
|
import { DialogTrigger } from 'react-aria-components'
|
||||||
import { Button, Text } from '@/primitives'
|
import { Button, Menu, Text } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -10,6 +10,11 @@ import { authUrl, useUser, UserAware } from '@/features/auth'
|
|||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
import { useCreateRoom } from '@/features/rooms'
|
import { useCreateRoom } from '@/features/rooms'
|
||||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||||
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||||
|
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||||
|
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||||
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
@@ -19,13 +24,8 @@ export const Home = () => {
|
|||||||
userChoices: { username },
|
userChoices: { username },
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom({
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
onSuccess: (data) => {
|
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||||
navigateTo('room', data.slug, {
|
|
||||||
state: { create: true, initialRoomData: data },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserAware>
|
<UserAware>
|
||||||
@@ -43,21 +43,43 @@ export const Home = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<HStack gap="gutter">
|
<HStack gap="gutter">
|
||||||
<Button
|
{isLoggedIn ? (
|
||||||
variant="primary"
|
<Menu>
|
||||||
onPress={
|
<Button variant="primary">{t('createMeeting')}</Button>
|
||||||
isLoggedIn
|
<RACMenu>
|
||||||
? async () => {
|
<MenuItem
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={async () => {
|
||||||
const slug = generateRoomId()
|
const slug = generateRoomId()
|
||||||
await createRoom({ slug, username })
|
createRoom({ slug, username }).then((data) =>
|
||||||
}
|
navigateTo('room', data.slug, {
|
||||||
: undefined
|
state: { create: true, initialRoomData: data },
|
||||||
}
|
})
|
||||||
href={isLoggedIn ? undefined : authUrl()}
|
)
|
||||||
>
|
}}
|
||||||
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
|
>
|
||||||
</Button>
|
<RiAddLine size={18} />
|
||||||
|
{t('createMenu.instantOption')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={() => {
|
||||||
|
const slug = generateRoomId()
|
||||||
|
createRoom({ slug, username }).then((data) =>
|
||||||
|
setLaterRoomId(data.slug)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiLink size={18} />
|
||||||
|
{t('createMenu.laterOption')}
|
||||||
|
</MenuItem>
|
||||||
|
</RACMenu>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" href={authUrl()}>
|
||||||
|
{t('login', { ns: 'global' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Button variant="primary" outline>
|
<Button variant="primary" outline>
|
||||||
{t('joinMeeting')}
|
{t('joinMeeting')}
|
||||||
@@ -66,6 +88,10 @@ export const Home = () => {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Centered>
|
</Centered>
|
||||||
|
<LaterMeetingDialog
|
||||||
|
roomId={laterRoomId || ''}
|
||||||
|
onOpenChange={() => setLaterRoomId(null)}
|
||||||
|
/>
|
||||||
</Screen>
|
</Screen>
|
||||||
</UserAware>
|
</UserAware>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
|
import { NotificationType } from './NotificationType'
|
||||||
|
import { Div } from '@/primitives'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||||
|
|
||||||
|
export const MainNotificationToast = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { triggerNotificationSound } = useNotificationSound()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showJoinNotification = (participant: Participant) => {
|
||||||
|
if (isMobileBrowser()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
triggerNotificationSound(NotificationType.Joined)
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
participant,
|
||||||
|
type: NotificationType.Joined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 5000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||||
|
}
|
||||||
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeJoinNotification = (participant: Participant) => {
|
||||||
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
|
(toast) =>
|
||||||
|
toast.content.participant === participant &&
|
||||||
|
toast.content.type === NotificationType.Joined
|
||||||
|
)
|
||||||
|
if (existingToast) {
|
||||||
|
toastQueue.close(existingToast.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
// fixme - close all related toasters when hands are lowered remotely
|
||||||
|
useEffect(() => {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
const handleNotificationReceived = (
|
||||||
|
payload: Uint8Array,
|
||||||
|
participant?: RemoteParticipant
|
||||||
|
) => {
|
||||||
|
if (!participant) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMobileBrowser()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const notification = decoder.decode(payload)
|
||||||
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
|
(toast) =>
|
||||||
|
toast.content.participant === participant &&
|
||||||
|
toast.content.type === NotificationType.Raised
|
||||||
|
)
|
||||||
|
if (existingToast && notification === NotificationType.Lowered) {
|
||||||
|
toastQueue.close(existingToast.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!existingToast && notification === NotificationType.Raised) {
|
||||||
|
triggerNotificationSound(NotificationType.Raised)
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
participant,
|
||||||
|
type: NotificationType.Raised,
|
||||||
|
},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.DataReceived, handleNotificationReceived)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.DataReceived, handleNotificationReceived)
|
||||||
|
}
|
||||||
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Div position="absolute" bottom={20} right={5} zIndex={1000}>
|
||||||
|
<ToastProvider />
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum NotificationType {
|
||||||
|
Joined = 'joined',
|
||||||
|
Default = 'default',
|
||||||
|
Raised = 'raised',
|
||||||
|
Lowered = 'lowered',
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useToast } from '@react-aria/toast'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import { ToastState } from '@react-stately/toast'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { ToastData } from './ToastProvider'
|
||||||
|
import type { QueuedToast } from '@react-stately/toast'
|
||||||
|
|
||||||
|
export const StyledToastContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
margin: 0.5,
|
||||||
|
boxShadow:
|
||||||
|
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
|
||||||
|
backgroundColor: '#494c4f',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
'&[data-entering]': { animation: 'fade 200ms' },
|
||||||
|
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
||||||
|
width: 'fit-content',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledToast = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '10px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
key: string
|
||||||
|
toast: QueuedToast<ToastData>
|
||||||
|
state: ToastState<ToastData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toast({ state, ...props }: ToastProps) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps, closeButtonProps } = useToast(
|
||||||
|
props,
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<StyledToast>
|
||||||
|
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||||
|
<Button square size="sm" invisible {...closeButtonProps}>
|
||||||
|
<RiCloseLine color="white" />
|
||||||
|
</Button>
|
||||||
|
</StyledToast>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useToast } from '@react-aria/toast'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import Source = Track.Source
|
||||||
|
|
||||||
|
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
|
import { StyledToastContainer, ToastProps } from './Toast'
|
||||||
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
|
import { Div } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const ClickableToast = styled(RACButton, {
|
||||||
|
base: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ToastJoined({ state, ...props }: ToastProps) {
|
||||||
|
const { t } = useTranslation('notifications')
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||||
|
props,
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
const participant = props.toast.content.participant
|
||||||
|
const trackReference = {
|
||||||
|
participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
const pinParticipant = () => {
|
||||||
|
layoutContext?.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<ClickableToast
|
||||||
|
ref={ref}
|
||||||
|
onPress={(e) => {
|
||||||
|
pinParticipant()
|
||||||
|
closeButtonProps.onPress?.(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack justify="center" alignItems="center" {...contentProps}>
|
||||||
|
<Div display="flex" overflow="hidden" width="128" height="72">
|
||||||
|
<ParticipantTile
|
||||||
|
trackRef={trackReference}
|
||||||
|
disableSpeakingIndicator={true}
|
||||||
|
disableMetadata={true}
|
||||||
|
style={{
|
||||||
|
borderRadius: '7px 0 0 7px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Div>
|
||||||
|
<Div padding={20} {...titleProps}>
|
||||||
|
{t('joined.description', {
|
||||||
|
name: participant.name || t('defaultName'),
|
||||||
|
})}
|
||||||
|
</Div>
|
||||||
|
</HStack>
|
||||||
|
</ClickableToast>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
||||||
|
import { ToastRegion } from './ToastRegion'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { NotificationType } from '../NotificationType'
|
||||||
|
|
||||||
|
export interface ToastData {
|
||||||
|
participant: Participant
|
||||||
|
type: NotificationType
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using a global queue for toasts allows for centralized management and queuing of notifications
|
||||||
|
// from anywhere in the app, providing greater flexibility in complex scenarios.
|
||||||
|
export const toastQueue = new ToastQueue<ToastData>({
|
||||||
|
maxVisibleToasts: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ToastProvider = ({ ...props }) => {
|
||||||
|
const state = useToastQueue<ToastData>(toastQueue)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.visibleToasts.length > 0 && (
|
||||||
|
<ToastRegion {...props} state={state} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useToast } from '@react-aria/toast'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
import { StyledToastContainer, ToastProps } from './Toast'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Button, Div } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiCloseLine, RiHand } from '@remixicon/react'
|
||||||
|
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
|
||||||
|
|
||||||
|
export function ToastRaised({ state, ...props }: ToastProps) {
|
||||||
|
const { t } = useTranslation('notifications')
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
|
||||||
|
props,
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
const participant = props.toast.content.participant
|
||||||
|
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<HStack
|
||||||
|
justify="center"
|
||||||
|
alignItems="center"
|
||||||
|
{...contentProps}
|
||||||
|
padding={14}
|
||||||
|
gap={0}
|
||||||
|
>
|
||||||
|
<RiHand
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
marginRight: '1rem',
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Div {...titleProps} marginRight={0.5}>
|
||||||
|
{t('raised.description', {
|
||||||
|
name: participant.name || t('defaultName'),
|
||||||
|
})}
|
||||||
|
</Div>
|
||||||
|
{!isParticipantsOpen && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
style={{
|
||||||
|
color: '#60a5fa',
|
||||||
|
}}
|
||||||
|
onPress={(e) => {
|
||||||
|
toggleParticipants()
|
||||||
|
closeButtonProps.onPress?.(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('raised.cta')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button square size="sm" invisible {...closeButtonProps}>
|
||||||
|
<RiCloseLine size={18} color="white" />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
||||||
|
import type { ToastState } from '@react-stately/toast'
|
||||||
|
import { Toast } from './Toast'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { NotificationType } from '../NotificationType'
|
||||||
|
import { ToastJoined } from './ToastJoined'
|
||||||
|
import { ToastData } from './ToastProvider'
|
||||||
|
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
|
||||||
|
|
||||||
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
|
state: ToastState<ToastData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { regionProps } = useToastRegion(props, state, ref)
|
||||||
|
return (
|
||||||
|
<div {...regionProps} ref={ref} className="toast-region">
|
||||||
|
{state.visibleToasts.map((toast) => {
|
||||||
|
if (toast.content?.type === NotificationType.Joined) {
|
||||||
|
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||||
|
}
|
||||||
|
if (toast.content?.type === NotificationType.Raised) {
|
||||||
|
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||||
|
}
|
||||||
|
return <Toast key={toast.key} toast={toast} state={state} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import useSound from 'use-sound'
|
||||||
|
|
||||||
|
// fixme - handle dynamic audio output changes
|
||||||
|
export const useNotificationSound = () => {
|
||||||
|
const [play] = useSound('./sounds/notifications.mp3', {
|
||||||
|
sprite: {
|
||||||
|
joined: [0, 1150],
|
||||||
|
raised: [1400, 180],
|
||||||
|
message: [1580, 300],
|
||||||
|
waiting: [2039, 710],
|
||||||
|
success: [2740, 1304],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const triggerNotificationSound = (type: string) => {
|
||||||
|
play({ id: type })
|
||||||
|
}
|
||||||
|
return { triggerNotificationSound }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const StyledContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
borderRadius: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledChild = styled('div', {
|
||||||
|
base: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
width: '4px',
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
animationDuration: '400ms',
|
||||||
|
animationName: 'active_speaker',
|
||||||
|
animationIterationCount: 'infinite',
|
||||||
|
animationDirection: 'alternate',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
true: {
|
||||||
|
animationIterationCount: 'infinite',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
animationIterationCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
small: {
|
||||||
|
animationName: 'active_speake_small',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<StyledChild
|
||||||
|
active={isSpeaking}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
animationDelay: '300ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledChild
|
||||||
|
active={isSpeaking}
|
||||||
|
style={{
|
||||||
|
animationDelay: '100ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledChild
|
||||||
|
active={isSpeaking}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
animationDelay: '500ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -50,7 +50,6 @@ export const Conference = ({
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: fetchKey,
|
queryKey: fetchKey,
|
||||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||||
enabled: !initialRoomData,
|
|
||||||
initialData: initialRoomData,
|
initialData: initialRoomData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchRoom({
|
fetchRoom({
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, Dialog, Input, type DialogProps } from '@/primitives'
|
import { Div, Button, type DialogProps, P } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
|
import { Text, text } from '@/primitives/Text'
|
||||||
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloseLine,
|
||||||
|
RiFileCopyLine,
|
||||||
|
RiSpam2Fill,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// fixme - extract in a proper primitive this dialog without overlay
|
||||||
|
const StyledRACDialog = styled(Dialog, {
|
||||||
|
base: {
|
||||||
|
position: 'fixed',
|
||||||
|
left: '0.75rem',
|
||||||
|
bottom: 80,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
width: '24.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
boxShadow:
|
||||||
|
'0 1px 2px 0 rgba(60, 64, 67, .3), 0 2px 6px 2px rgba(60, 64, 67, .15)',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
'&[data-entering]': { animation: 'fade 200ms' },
|
||||||
|
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const InviteDialog = ({
|
export const InviteDialog = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -11,48 +40,102 @@ export const InviteDialog = ({
|
|||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const roomUrl = getRouteUrl('room', roomId)
|
const roomUrl = getRouteUrl('room', roomId)
|
||||||
|
|
||||||
const copyLabel = t('shareDialog.copy')
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
const copiedLabel = t('shareDialog.copied')
|
|
||||||
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (copyLinkLabel == copiedLabel) {
|
if (isCopied) {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||||
setCopyLinkLabel(copyLabel)
|
return () => clearTimeout(timeout)
|
||||||
}, 5000)
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [copyLinkLabel, copyLabel, copiedLabel])
|
}, [isCopied])
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...dialogProps} title={t('shareDialog.heading')}>
|
<StyledRACDialog {...dialogProps}>
|
||||||
<HStack alignItems="stretch" gap="gutter">
|
{({ close }) => (
|
||||||
<Div flex="1">
|
<VStack
|
||||||
<Input
|
alignItems="left"
|
||||||
type="text"
|
justify="start"
|
||||||
aria-label={t('shareDialog.inputLabel')}
|
gap={0}
|
||||||
value={roomUrl}
|
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||||
readOnly
|
>
|
||||||
onClick={(e) => {
|
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||||
e.currentTarget.select()
|
{t('shareDialog.heading')}
|
||||||
}}
|
</Heading>
|
||||||
/>
|
<Div position="absolute" top="5" right="5">
|
||||||
</Div>
|
<Button
|
||||||
<Div minWidth="8rem">
|
invisible
|
||||||
|
size="xs"
|
||||||
|
onPress={() => {
|
||||||
|
dialogProps.onClose?.()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
aria-label={t('closeDialog')}
|
||||||
|
>
|
||||||
|
<RiCloseLine />
|
||||||
|
</Button>
|
||||||
|
</Div>
|
||||||
|
<P>{t('shareDialog.description')}</P>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant={isCopied ? 'success' : 'primary'}
|
||||||
size="sm"
|
size="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
aria-label={t('shareDialog.copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigator.clipboard.writeText(roomUrl)
|
navigator.clipboard.writeText(roomUrl)
|
||||||
setCopyLinkLabel(copiedLabel)
|
setIsCopied(true)
|
||||||
}}
|
}}
|
||||||
|
onHoverChange={setIsHovered}
|
||||||
>
|
>
|
||||||
{copyLinkLabel}
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||||
|
{t('shareDialog.copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||||
|
/>
|
||||||
|
{isHovered ? (
|
||||||
|
t('shareDialog.copy')
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
|
textWrap: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Div>
|
<HStack>
|
||||||
</HStack>
|
<div
|
||||||
</Dialog>
|
style={{
|
||||||
|
backgroundColor: '#d9e5ff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
|
||||||
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('shareDialog.permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const buildServerApiUrl = (origin: string, path: string) => {
|
||||||
|
const sanitizedOrigin = origin.replace(/\/$/, '')
|
||||||
|
const sanitizedPath = path.replace(/^\//, '')
|
||||||
|
return `${sanitizedOrigin}/${sanitizedPath}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ApiError } from '@/api/ApiError'
|
||||||
|
|
||||||
|
export const fetchServerApi = async <T = Record<string, unknown>>(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response.status, result)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
|
||||||
|
export const useLowerHandParticipant = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
|
||||||
|
const lowerHandParticipant = (participant: Participant) => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
const newMetadata = JSON.parse(participant.metadata || '{}')
|
||||||
|
newMetadata.raised = !newMetadata.raised
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(
|
||||||
|
data.livekit.url,
|
||||||
|
'twirp/livekit.RoomService/UpdateParticipant'
|
||||||
|
),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
room: data.livekit.room,
|
||||||
|
identity: participant.identity,
|
||||||
|
metadata: JSON.stringify(newMetadata),
|
||||||
|
permission: participant.permissions,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { lowerHandParticipant }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant'
|
||||||
|
|
||||||
|
export const useLowerHandParticipants = () => {
|
||||||
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
|
|
||||||
|
const lowerHandParticipants = (participants: Array<Participant>) => {
|
||||||
|
try {
|
||||||
|
const promises = participants.map((participant) =>
|
||||||
|
lowerHandParticipant(participant)
|
||||||
|
)
|
||||||
|
return Promise.all(promises)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('An error occurred while lowering hands.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lowerHandParticipants }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Participant, Track } from 'livekit-client'
|
||||||
|
import Source = Track.Source
|
||||||
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
|
||||||
|
export const useMuteParticipant = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
|
||||||
|
const muteParticipant = (participant: Participant) => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
const trackSid = participant.getTrackPublication(
|
||||||
|
Source.Microphone
|
||||||
|
)?.trackSid
|
||||||
|
|
||||||
|
if (!trackSid) {
|
||||||
|
throw new Error('Missing audio track')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(
|
||||||
|
data.livekit.url,
|
||||||
|
'twirp/livekit.RoomService/MutePublishedTrack'
|
||||||
|
),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
room: data.livekit.room,
|
||||||
|
identity: participant.identity,
|
||||||
|
muted: true,
|
||||||
|
track_sid: trackSid,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { muteParticipant }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
|
import { useParams } from 'wouter'
|
||||||
|
|
||||||
|
export const useRecordRoom = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
const { roomId: roomSlug } = useParams()
|
||||||
|
|
||||||
|
const recordRoom = () => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
if (!roomSlug) {
|
||||||
|
throw new Error('Room ID is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(
|
||||||
|
data.livekit.url,
|
||||||
|
'/twirp/livekit.Egress/StartRoomCompositeEgress'
|
||||||
|
),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_name: data.livekit.room,
|
||||||
|
audio_only: true,
|
||||||
|
file_outputs: [
|
||||||
|
{
|
||||||
|
file_extension: 'ogg',
|
||||||
|
filepath: `{room_name}_{time}_${roomSlug}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecordingRoom = (egressId: string) => {
|
||||||
|
if (!data || !data?.livekit) {
|
||||||
|
throw new Error('Room data is not available')
|
||||||
|
}
|
||||||
|
return fetchServerApi(
|
||||||
|
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
|
||||||
|
data.livekit.token,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
egressId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recordRoom, stopRecordingRoom }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ParticipantTile } from './ParticipantTile'
|
||||||
|
import { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
|
return <ParticipantTile trackRef={trackRef} {...htmlProps} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||||
|
import { Participant, Track } from 'livekit-client'
|
||||||
|
import Source = Track.Source
|
||||||
|
import { Div } from '@/primitives'
|
||||||
|
import { RiMicOffFill } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const MutedMicIndicator = ({
|
||||||
|
participant,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
}) => {
|
||||||
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
|
participant: participant,
|
||||||
|
source: Source.Microphone,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isMuted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Div padding={0.25} backgroundColor="red" borderRadius="4px">
|
||||||
|
<RiMicOffFill size={16} color="white" />
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { useIsSpeaking } from '@livekit/components-react'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#3d4043', // fixme - copied from gmeet
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ParticipantPlaceholderProps = {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantPlaceholder = ({
|
||||||
|
participant,
|
||||||
|
}: ParticipantPlaceholderProps) => {
|
||||||
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
|
const participantColor = getParticipantColor(participant)
|
||||||
|
|
||||||
|
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||||
|
const { width, height } = useSize(placeholderEl)
|
||||||
|
|
||||||
|
const minDimension = Math.min(width, height)
|
||||||
|
const avatarSize = useMemo(
|
||||||
|
() => Math.min(Math.round(minDimension * 0.9), 160),
|
||||||
|
[minDimension]
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
width: '80%',
|
||||||
|
maxWidth: `${avatarSize}px`,
|
||||||
|
fontSize: `${initialSize}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
|
||||||
|
<Avatar
|
||||||
|
name={participant.name}
|
||||||
|
bgColor={participantColor}
|
||||||
|
context="placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StyledParticipantPlaceHolder>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
AudioTrack,
|
||||||
|
ConnectionQualityIndicator,
|
||||||
|
FocusToggle,
|
||||||
|
LockLockedIcon,
|
||||||
|
ParticipantName,
|
||||||
|
ParticipantTileProps,
|
||||||
|
ScreenShareIcon,
|
||||||
|
useEnsureTrackRef,
|
||||||
|
useFeatureContext,
|
||||||
|
useIsEncrypted,
|
||||||
|
useMaybeLayoutContext,
|
||||||
|
useMaybeTrackRefContext,
|
||||||
|
useParticipantTile,
|
||||||
|
VideoTrack,
|
||||||
|
TrackRefContext,
|
||||||
|
ParticipantContextIfNeeded,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
isTrackReference,
|
||||||
|
isTrackReferencePinned,
|
||||||
|
TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
|
||||||
|
|
||||||
|
export function TrackRefContextIfNeeded(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
trackRef?: TrackReferenceOrPlaceholder
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const hasContext = !!useMaybeTrackRefContext()
|
||||||
|
return props.trackRef && !hasContext ? (
|
||||||
|
<TrackRefContext.Provider value={props.trackRef}>
|
||||||
|
{props.children}
|
||||||
|
</TrackRefContext.Provider>
|
||||||
|
) : (
|
||||||
|
<>{props.children}</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||||
|
disableMetadata?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantTile: (
|
||||||
|
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||||
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ParticipantTileExtendedProps
|
||||||
|
>(function ParticipantTile(
|
||||||
|
{
|
||||||
|
trackRef,
|
||||||
|
children,
|
||||||
|
onParticipantClick,
|
||||||
|
disableSpeakingIndicator,
|
||||||
|
disableMetadata,
|
||||||
|
...htmlProps
|
||||||
|
}: ParticipantTileExtendedProps,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const trackReference = useEnsureTrackRef(trackRef)
|
||||||
|
|
||||||
|
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||||
|
htmlProps,
|
||||||
|
disableSpeakingIndicator,
|
||||||
|
onParticipantClick,
|
||||||
|
trackRef: trackReference,
|
||||||
|
})
|
||||||
|
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
|
||||||
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
|
|
||||||
|
const handleSubscribe = React.useCallback(
|
||||||
|
(subscribed: boolean) => {
|
||||||
|
if (
|
||||||
|
trackReference.source &&
|
||||||
|
!subscribed &&
|
||||||
|
layoutContext &&
|
||||||
|
layoutContext.pin.dispatch &&
|
||||||
|
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||||
|
) {
|
||||||
|
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[trackReference, layoutContext]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { isHandRaised } = useRaisedHand({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||||
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
{isTrackReference(trackReference) &&
|
||||||
|
(trackReference.publication?.kind === 'video' ||
|
||||||
|
trackReference.source === Track.Source.Camera ||
|
||||||
|
trackReference.source === Track.Source.ScreenShare) ? (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
isTrackReference(trackReference) && (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-placeholder">
|
||||||
|
<ParticipantPlaceholder
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!disableMetadata && (
|
||||||
|
<div className="lk-participant-metadata">
|
||||||
|
<HStack gap={0.25}>
|
||||||
|
<MutedMicIndicator
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="lk-participant-metadata-item"
|
||||||
|
style={{
|
||||||
|
minHeight: '24px',
|
||||||
|
backgroundColor: isHandRaised ? 'white' : undefined,
|
||||||
|
color: isHandRaised ? 'black' : undefined,
|
||||||
|
transition: 'background 200ms ease, color 400ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trackReference.source === Track.Source.Camera ? (
|
||||||
|
<>
|
||||||
|
{isHandRaised && (
|
||||||
|
<RiHand
|
||||||
|
color="black"
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEncrypted && (
|
||||||
|
<LockLockedIcon
|
||||||
|
style={{ marginRight: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ParticipantName />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||||
|
<ParticipantName>'s screen</ParticipantName>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
|
||||||
|
</ParticipantContextIfNeeded>
|
||||||
|
</TrackRefContextIfNeeded>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
|
export const RecordingIndicator = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [isRecording, setIsRecording] = useState(room.isRecording)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRecordingStatusChanges = (isRecording: boolean) => {
|
||||||
|
setIsRecording(isRecording)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Room is recording: {isRecording ? 'yes' : 'no'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiChat1Line } from '@remixicon/react'
|
import { RiChat1Line } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useLayoutContext } from '@livekit/components-react'
|
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { participantsStore } from '@/stores/participants'
|
|
||||||
|
|
||||||
export const ChatToggle = () => {
|
export const ChatToggle = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const { dispatch, state } = useLayoutContext().widget
|
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
|
||||||
const tooltipLabel = state?.showChat ? 'open' : 'closed'
|
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
const participantsSnap = useSnapshot(participantsStore)
|
|
||||||
const showParticipants = participantsSnap.showParticipants
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -22,21 +17,17 @@ export const ChatToggle = () => {
|
|||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<ToggleButton
|
||||||
toggle
|
|
||||||
square
|
square
|
||||||
legacyStyle
|
legacyStyle
|
||||||
aria-label={t(`controls.chat.${tooltipLabel}`)}
|
aria-label={t(`controls.chat.${tooltipLabel}`)}
|
||||||
tooltip={t(`controls.chat.${tooltipLabel}`)}
|
tooltip={t(`controls.chat.${tooltipLabel}`)}
|
||||||
isSelected={state?.showChat}
|
isSelected={isChatOpen}
|
||||||
onPress={() => {
|
onPress={() => toggleChat()}
|
||||||
if (showParticipants) participantsStore.showParticipants = false
|
|
||||||
if (dispatch) dispatch({ msg: 'toggle_chat' })
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RiChat1Line />
|
<RiChat1Line />
|
||||||
</Button>
|
</ToggleButton>
|
||||||
{!!state?.unreadMessages && (
|
{!!unreadMessages && (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { ToggleButton } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
|
export const HandToggle = () => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||||
|
participant: room.localParticipant,
|
||||||
|
})
|
||||||
|
|
||||||
|
const label = isHandRaised
|
||||||
|
? t('controls.hand.lower')
|
||||||
|
: t('controls.hand.raise')
|
||||||
|
|
||||||
|
const notifyOtherParticipants = (isHandRaised: boolean) => {
|
||||||
|
room.localParticipant.publishData(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
!isHandRaised ? NotificationType.Raised : NotificationType.Lowered
|
||||||
|
),
|
||||||
|
{
|
||||||
|
reliable: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
square
|
||||||
|
legacyStyle
|
||||||
|
aria-label={label}
|
||||||
|
tooltip={label}
|
||||||
|
isSelected={isHandRaised}
|
||||||
|
onPress={() => {
|
||||||
|
notifyOtherParticipants(isHandRaised)
|
||||||
|
toggleRaisedHand()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiHand />
|
||||||
|
</ToggleButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
-14
@@ -1,22 +1,33 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiMore2Line } from '@remixicon/react'
|
import { RiMore2Line } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { OptionsMenu } from '@/features/rooms/livekit/components/controls/Options/OptionsMenu.tsx'
|
|
||||||
import { MenuTrigger } from 'react-aria-components'
|
import { useState } from 'react'
|
||||||
|
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
|
||||||
|
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
||||||
|
|
||||||
|
export type DialogState = 'username' | 'settings' | null
|
||||||
|
|
||||||
export const OptionsButton = () => {
|
export const OptionsButton = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
|
||||||
return (
|
return (
|
||||||
<MenuTrigger>
|
<>
|
||||||
<Button
|
<Menu>
|
||||||
square
|
<Button
|
||||||
legacyStyle
|
square
|
||||||
aria-label={t('options.buttonLabel')}
|
legacyStyle
|
||||||
tooltip={t('options.buttonLabel')}
|
aria-label={t('options.buttonLabel')}
|
||||||
>
|
tooltip={t('options.buttonLabel')}
|
||||||
<RiMore2Line />
|
>
|
||||||
</Button>
|
<RiMore2Line />
|
||||||
<OptionsMenu />
|
</Button>
|
||||||
</MenuTrigger>
|
<OptionsMenuItems onOpenDialog={setDialogOpen} />
|
||||||
|
</Menu>
|
||||||
|
<SettingsDialogExtended
|
||||||
|
isOpen={dialogOpen === 'settings'}
|
||||||
|
onOpenChange={(v) => !v && setDialogOpen(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import {
|
|
||||||
RiFeedbackLine,
|
|
||||||
RiQuestionLine,
|
|
||||||
RiSettings3Line,
|
|
||||||
RiUser5Line,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import {
|
|
||||||
Menu as RACMenu,
|
|
||||||
MenuItem as RACMenuItem,
|
|
||||||
Popover as RACPopover,
|
|
||||||
Separator as RACSeparator,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { SettingsDialog } from '@/features/settings'
|
|
||||||
import { UsernameDialog } from '../../dialogs/UsernameDialog'
|
|
||||||
|
|
||||||
// Styled components to be refactored
|
|
||||||
const StyledMenu = styled(RACMenu, {
|
|
||||||
base: {
|
|
||||||
maxHeight: 'inherit',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '2px',
|
|
||||||
minWidth: '150px',
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledMenuItem = styled(RACMenuItem, {
|
|
||||||
base: {
|
|
||||||
margin: '2px',
|
|
||||||
padding: '0.286rem 0.571rem',
|
|
||||||
borderRadius: '6px',
|
|
||||||
outline: 'none',
|
|
||||||
cursor: 'default',
|
|
||||||
color: 'black',
|
|
||||||
fontSize: '1.072rem',
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '20px',
|
|
||||||
forcedColorAdjust: 'none',
|
|
||||||
'&[data-focused]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledPopover = styled(RACPopover, {
|
|
||||||
base: {
|
|
||||||
border: '1px solid #9ca3af',
|
|
||||||
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.1)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: 'white',
|
|
||||||
color: 'var(--text-color)',
|
|
||||||
outline: 'none',
|
|
||||||
minWidth: '112px',
|
|
||||||
width: '300px',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledSeparator = styled(RACSeparator, {
|
|
||||||
base: {
|
|
||||||
height: '1px',
|
|
||||||
background: '#9ca3af',
|
|
||||||
margin: '2px 4px',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type DialogState = 'username' | 'settings' | null
|
|
||||||
|
|
||||||
export const OptionsMenu = () => {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StyledPopover>
|
|
||||||
<StyledMenu>
|
|
||||||
<StyledMenuItem onAction={() => setDialogOpen('username')}>
|
|
||||||
<RiUser5Line size={18} />
|
|
||||||
{t('options.items.username')}
|
|
||||||
</StyledMenuItem>
|
|
||||||
<StyledSeparator />
|
|
||||||
<StyledMenuItem
|
|
||||||
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<RiQuestionLine size={18} />
|
|
||||||
{t('options.items.support')}
|
|
||||||
</StyledMenuItem>
|
|
||||||
<StyledMenuItem
|
|
||||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<RiFeedbackLine size={18} />
|
|
||||||
{t('options.items.feedbacks')}
|
|
||||||
</StyledMenuItem>
|
|
||||||
<StyledMenuItem onAction={() => setDialogOpen('settings')}>
|
|
||||||
<RiSettings3Line size={18} />
|
|
||||||
{t('options.items.settings')}
|
|
||||||
</StyledMenuItem>
|
|
||||||
</StyledMenu>
|
|
||||||
</StyledPopover>
|
|
||||||
<UsernameDialog
|
|
||||||
isOpen={dialogOpen === 'username'}
|
|
||||||
onOpenChange={(v) => !v && setDialogOpen(null)}
|
|
||||||
/>
|
|
||||||
<SettingsDialog
|
|
||||||
isOpen={dialogOpen === 'settings'}
|
|
||||||
onOpenChange={(v) => !v && setDialogOpen(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||||
|
import {
|
||||||
|
RiFeedbackLine,
|
||||||
|
RiQuestionLine,
|
||||||
|
RiSettings3Line,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Dispatch, SetStateAction } from 'react'
|
||||||
|
import { DialogState } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
|
||||||
|
import { RecordingMenuItem } from './RecordingMenuItem.tsx'
|
||||||
|
|
||||||
|
// @todo try refactoring it to use MenuList component
|
||||||
|
export const OptionsMenuItems = ({
|
||||||
|
onOpenDialog,
|
||||||
|
}: {
|
||||||
|
onOpenDialog: Dispatch<SetStateAction<DialogState>>
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RACMenu
|
||||||
|
style={{
|
||||||
|
minWidth: '150px',
|
||||||
|
width: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
|
||||||
|
target="_blank"
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
>
|
||||||
|
<RiQuestionLine size={18} />
|
||||||
|
{t('options.items.support')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||||
|
target="_blank"
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
>
|
||||||
|
<RiFeedbackLine size={18} />
|
||||||
|
{t('options.items.feedbacks')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={() => onOpenDialog('settings')}
|
||||||
|
>
|
||||||
|
<RiSettings3Line size={18} />
|
||||||
|
{t('options.items.settings')}
|
||||||
|
</MenuItem>
|
||||||
|
<RecordingMenuItem />
|
||||||
|
</RACMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
|
||||||
|
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
|
||||||
|
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { egressStore } from '@/stores/egress.tsx'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
export const RecordingMenuItem = () => {
|
||||||
|
const { recordRoom, stopRecordingRoom } = useRecordRoom()
|
||||||
|
|
||||||
|
const egressSnap = useSnapshot(egressStore)
|
||||||
|
const egressId = egressSnap.egressId
|
||||||
|
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
if (egressId) {
|
||||||
|
setIsPending(true)
|
||||||
|
const response = await stopRecordingRoom(egressId)
|
||||||
|
console.log(response)
|
||||||
|
egressStore.egressId = undefined
|
||||||
|
setIsPending(false)
|
||||||
|
} else {
|
||||||
|
setIsPending(true)
|
||||||
|
const response = await recordRoom()
|
||||||
|
egressStore.egressId = response['egress_id'] as string
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
isDisabled={isPending}
|
||||||
|
className={menuItemRecipe({ icon: true })}
|
||||||
|
onAction={handleAction}
|
||||||
|
>
|
||||||
|
{egressId ? (
|
||||||
|
<>
|
||||||
|
<RiPauseCircleLine size={18} />
|
||||||
|
Stop recording room
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiRecordCircleLine size={18} />
|
||||||
|
Record room
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Text } from '@/primitives/Text'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { isLocal } from '@/utils/livekit'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant.ts'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
|
||||||
|
type HandRaisedListItemProps = {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandRaisedListItem = ({
|
||||||
|
participant,
|
||||||
|
}: HandRaisedListItemProps) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
role="listitem"
|
||||||
|
justify="space-between"
|
||||||
|
key={participant.identity}
|
||||||
|
id={participant.identity}
|
||||||
|
className={css({
|
||||||
|
padding: '0.25rem 0',
|
||||||
|
width: 'full',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HStack>
|
||||||
|
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||||
|
<Text
|
||||||
|
variant={'sm'}
|
||||||
|
className={css({
|
||||||
|
userSelect: 'none',
|
||||||
|
cursor: 'default',
|
||||||
|
display: 'flex',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '120px',
|
||||||
|
display: 'block',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
{isLocal(participant) && (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
marginLeft: '.25rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
({t('participants.you')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
size="sm"
|
||||||
|
onPress={() => lowerHandParticipant(participant)}
|
||||||
|
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||||
|
>
|
||||||
|
<RiHand />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { useLowerHandParticipants } from '@/features/rooms/livekit/api/lowerHandParticipants'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
type LowerAllHandsButtonProps = {
|
||||||
|
participants: Array<Participant>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LowerAllHandsButton = ({
|
||||||
|
participants,
|
||||||
|
}: LowerAllHandsButtonProps) => {
|
||||||
|
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label={t('participants.lowerParticipantsHand')}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
variant="text"
|
||||||
|
onPress={() => lowerHandParticipants(participants)}
|
||||||
|
>
|
||||||
|
{t('participants.lowerParticipantsHand')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Text } from '@/primitives/Text'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
import { Participant, Track } from 'livekit-client'
|
||||||
|
import { isLocal } from '@/utils/livekit'
|
||||||
|
import {
|
||||||
|
useIsSpeaking,
|
||||||
|
useTrackMutedIndicator,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import Source = Track.Source
|
||||||
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
|
import { Button, Dialog, P } from '@/primitives'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/livekit/api/muteParticipant'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
|
participant: participant,
|
||||||
|
source: Source.Microphone,
|
||||||
|
})
|
||||||
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
size="sm"
|
||||||
|
tooltip={
|
||||||
|
isLocal(participant)
|
||||||
|
? t('participants.muteYourself')
|
||||||
|
: t('participants.muteParticipant', {
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isDisabled={isMuted}
|
||||||
|
onPress={() =>
|
||||||
|
!isMuted && isLocal(participant)
|
||||||
|
? muteParticipant(participant)
|
||||||
|
: setIsAlertOpen(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isMuted ? (
|
||||||
|
<RiMicOffFill color={'gray'} />
|
||||||
|
) : (
|
||||||
|
<RiMicFill
|
||||||
|
style={{
|
||||||
|
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onSubmit={() =>
|
||||||
|
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||||
|
}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantListItemProps = {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantListItem = ({
|
||||||
|
participant,
|
||||||
|
}: ParticipantListItemProps) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const name = participant.name || participant.identity
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
role="listitem"
|
||||||
|
justify="space-between"
|
||||||
|
key={participant.identity}
|
||||||
|
id={participant.identity}
|
||||||
|
className={css({
|
||||||
|
padding: '0.25rem 0',
|
||||||
|
width: 'full',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HStack>
|
||||||
|
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||||
|
<Text
|
||||||
|
variant={'sm'}
|
||||||
|
className={css({
|
||||||
|
userSelect: 'none',
|
||||||
|
cursor: 'default',
|
||||||
|
display: 'flex',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '120px',
|
||||||
|
display: 'block',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
{isLocal(participant) && (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
marginLeft: '.25rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
({t('participants.you')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack>
|
||||||
|
<MicIndicator participant={participant} />
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { ToggleButton } from 'react-aria-components'
|
||||||
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const ToggleHeader = styled(ToggleButton, {
|
||||||
|
base: {
|
||||||
|
minHeight: '40px', //fixme hardcoded value
|
||||||
|
paddingRight: '.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'background 200ms',
|
||||||
|
borderTopRadius: '7px',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = styled('div', {
|
||||||
|
base: {
|
||||||
|
border: '1px solid #dadce0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '0 .625rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ListContainer = styled(VStack, {
|
||||||
|
base: {
|
||||||
|
borderTop: '1px solid #dadce0',
|
||||||
|
alignItems: 'start',
|
||||||
|
overflowY: 'scroll',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
minHeight: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
paddingX: '1rem',
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ParticipantsCollapsableListProps = {
|
||||||
|
heading: string
|
||||||
|
participants: Array<Participant>
|
||||||
|
renderParticipant: (participant: Participant) => JSX.Element
|
||||||
|
action?: () => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantsCollapsableList = ({
|
||||||
|
heading,
|
||||||
|
participants,
|
||||||
|
renderParticipant,
|
||||||
|
action,
|
||||||
|
}: ParticipantsCollapsableListProps) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
|
||||||
|
name: heading,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ToggleHeader
|
||||||
|
isSelected={isOpen}
|
||||||
|
aria-label={label}
|
||||||
|
onPress={() => setIsOpen(!isOpen)}
|
||||||
|
style={{
|
||||||
|
borderRadius: !isOpen ? '7px' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
justify="space-between"
|
||||||
|
className={css({
|
||||||
|
margin: '0 1.25rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
<div>{participants?.length || 0}</div>
|
||||||
|
</HStack>
|
||||||
|
<RiArrowUpSLine
|
||||||
|
size={32}
|
||||||
|
style={{
|
||||||
|
transform: isOpen ? 'rotate(-180deg)' : undefined,
|
||||||
|
transition: 'transform 200ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ToggleHeader>
|
||||||
|
{isOpen && (
|
||||||
|
<ListContainer>
|
||||||
|
{action && action()}
|
||||||
|
{participants.map((participant) => renderParticipant(participant))}
|
||||||
|
</ListContainer>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+64
-106
@@ -1,42 +1,17 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import * as React from 'react'
|
|
||||||
import { useParticipants } from '@livekit/components-react'
|
import { useParticipants } from '@livekit/components-react'
|
||||||
|
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { Box, Button, Div } from '@/primitives'
|
import { Box, Button, Div, H } from '@/primitives'
|
||||||
import { HStack, VStack } from '@/styled-system/jsx'
|
import { text } from '@/primitives/Text'
|
||||||
import { Text, text } from '@/primitives/Text'
|
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import { capitalize } from '@/utils/capitalize'
|
|
||||||
import { participantsStore } from '@/stores/participants'
|
import { participantsStore } from '@/stores/participants'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
|
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
|
||||||
|
import { ParticipantListItem } from '@/features/rooms/livekit/components/controls/Participants/ParticipantListItem'
|
||||||
export type AvatarProps = React.HTMLAttributes<HTMLSpanElement> & {
|
import { ParticipantsCollapsableList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList'
|
||||||
name: string
|
import { HandRaisedListItem } from '@/features/rooms/livekit/components/controls/Participants/HandRaisedListItem'
|
||||||
size?: number
|
import { LowerAllHandsButton } from '@/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton'
|
||||||
}
|
|
||||||
|
|
||||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
|
||||||
export const Avatar = ({ name, size = 32 }: AvatarProps) => (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
minWidth: `${size}px`,
|
|
||||||
minHeight: `${size}px`,
|
|
||||||
backgroundColor: '#3498db',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: '1.25rem',
|
|
||||||
userSelect: 'none',
|
|
||||||
cursor: 'default',
|
|
||||||
color: 'white',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{name?.trim()?.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||||
export const ParticipantsList = () => {
|
export const ParticipantsList = () => {
|
||||||
@@ -49,43 +24,48 @@ export const ParticipantsList = () => {
|
|||||||
updateOnlyOn: allParticipantRoomEvents,
|
updateOnlyOn: allParticipantRoomEvents,
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedParticipants = participants.map((participant) => ({
|
const sortedRemoteParticipants = participants
|
||||||
name: participant.name || participant.identity,
|
|
||||||
id: participant.identity,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const sortedRemoteParticipants = formattedParticipants
|
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((participantA, participantB) => {
|
||||||
|
const nameA = participantA.name || participantA.identity
|
||||||
|
const nameB = participantB.name || participantB.identity
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
|
||||||
const allParticipants = [
|
const sortedParticipants = [
|
||||||
formattedParticipants[0], // first participant returned by the hook, is always the local one
|
participants[0], // first participant returned by the hook, is always the local one
|
||||||
...sortedRemoteParticipants,
|
...sortedRemoteParticipants,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const raisedHandParticipants = participants.filter((participant) => {
|
||||||
|
const data = JSON.parse(participant.metadata || '{}')
|
||||||
|
return data.raised
|
||||||
|
})
|
||||||
|
|
||||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
size="sm"
|
size="sm"
|
||||||
minWidth="300px"
|
minWidth="360px"
|
||||||
className={css({
|
className={css({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||||
|
padding: 0,
|
||||||
|
gap: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
<Heading
|
||||||
<span>{t('participants.heading')}</span>{' '}
|
slot="title"
|
||||||
<span
|
level={1}
|
||||||
className={css({
|
className={text({ variant: 'h2' })}
|
||||||
marginLeft: '0.75rem',
|
style={{
|
||||||
fontWeight: 'normal',
|
paddingLeft: '1.5rem',
|
||||||
fontSize: '1rem',
|
paddingTop: '1rem',
|
||||||
})}
|
}}
|
||||||
>
|
>
|
||||||
{participants?.length}
|
{t('participants.heading')}
|
||||||
</span>
|
|
||||||
</Heading>
|
</Heading>
|
||||||
<Div position="absolute" top="5" right="5">
|
<Div position="absolute" top="5" right="5">
|
||||||
<Button
|
<Button
|
||||||
@@ -98,63 +78,41 @@ export const ParticipantsList = () => {
|
|||||||
<RiCloseLine />
|
<RiCloseLine />
|
||||||
</Button>
|
</Button>
|
||||||
</Div>
|
</Div>
|
||||||
{participants?.length && (
|
<Div overflowY="scroll">
|
||||||
<VStack
|
<H
|
||||||
role="list"
|
lvl={2}
|
||||||
className={css({
|
className={css({
|
||||||
alignItems: 'start',
|
fontSize: '0.875rem',
|
||||||
gap: 'none',
|
fontWeight: 'bold',
|
||||||
overflowY: 'scroll',
|
color: '#5f6368',
|
||||||
overflowX: 'hidden',
|
padding: '0 1.5rem',
|
||||||
minHeight: 0,
|
marginBottom: '0.83em',
|
||||||
flexGrow: 1,
|
|
||||||
display: 'flex',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{allParticipants.map((participant, index) => (
|
{t('participants.subheading').toUpperCase()}
|
||||||
<HStack
|
</H>
|
||||||
role="listitem"
|
{raisedHandParticipants.length > 0 && (
|
||||||
key={participant.id}
|
<Div marginBottom=".9375rem">
|
||||||
id={participant.id}
|
<ParticipantsCollapsableList
|
||||||
className={css({
|
heading={t('participants.raisedHands')}
|
||||||
padding: '0.25rem 0',
|
participants={raisedHandParticipants}
|
||||||
})}
|
renderParticipant={(participant) => (
|
||||||
>
|
<HandRaisedListItem participant={participant} />
|
||||||
<Avatar name={participant.name} />
|
)}
|
||||||
<Text
|
action={() => (
|
||||||
variant={'sm'}
|
<LowerAllHandsButton participants={raisedHandParticipants} />
|
||||||
className={css({
|
)}
|
||||||
userSelect: 'none',
|
/>
|
||||||
cursor: 'default',
|
</Div>
|
||||||
display: 'flex',
|
)}
|
||||||
})}
|
<ParticipantsCollapsableList
|
||||||
>
|
heading={t('participants.contributors')}
|
||||||
<span
|
participants={sortedParticipants}
|
||||||
className={css({
|
renderParticipant={(participant) => (
|
||||||
whiteSpace: 'nowrap',
|
<ParticipantListItem participant={participant} />
|
||||||
overflow: 'hidden',
|
)}
|
||||||
textOverflow: 'ellipsis',
|
/>
|
||||||
maxWidth: '120px',
|
</Div>
|
||||||
display: 'block',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{capitalize(participant.name)}
|
|
||||||
</span>
|
|
||||||
{index === 0 && (
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
marginLeft: '.25rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
({t('participants.you')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-17
@@ -1,16 +1,13 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useLayoutContext, useParticipants } from '@livekit/components-react'
|
import { useParticipants } from '@livekit/components-react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
|
||||||
import { participantsStore } from '@/stores/participants'
|
|
||||||
|
|
||||||
export const ParticipantsToggle = () => {
|
export const ParticipantsToggle = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const { dispatch, state } = useLayoutContext().widget
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context could not be used due to inconsistent refresh behavior.
|
* Context could not be used due to inconsistent refresh behavior.
|
||||||
* The 'numParticipant' property on the room only updates when the room's metadata changes,
|
* The 'numParticipant' property on the room only updates when the room's metadata changes,
|
||||||
@@ -19,10 +16,9 @@ export const ParticipantsToggle = () => {
|
|||||||
const participants = useParticipants()
|
const participants = useParticipants()
|
||||||
const numParticipants = participants?.length
|
const numParticipants = participants?.length
|
||||||
|
|
||||||
const participantsSnap = useSnapshot(participantsStore)
|
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
|
||||||
const showParticipants = participantsSnap.showParticipants
|
|
||||||
|
|
||||||
const tooltipLabel = showParticipants ? 'open' : 'closed'
|
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -31,20 +27,16 @@ export const ParticipantsToggle = () => {
|
|||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<ToggleButton
|
||||||
toggle
|
|
||||||
square
|
square
|
||||||
legacyStyle
|
legacyStyle
|
||||||
aria-label={t(`controls.participants.${tooltipLabel}`)}
|
aria-label={t(`controls.participants.${tooltipLabel}`)}
|
||||||
tooltip={t(`controls.participants.${tooltipLabel}`)}
|
tooltip={t(`controls.participants.${tooltipLabel}`)}
|
||||||
isSelected={showParticipants}
|
isSelected={isParticipantsOpen}
|
||||||
onPress={() => {
|
onPress={() => toggleParticipants()}
|
||||||
if (dispatch && state?.showChat) dispatch({ msg: 'toggle_chat' })
|
|
||||||
participantsStore.showParticipants = !showParticipants
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RiGroupLine />
|
<RiGroupLine />
|
||||||
</Button>
|
</ToggleButton>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Field, Form, Dialog, DialogProps } from '@/primitives'
|
|
||||||
import {
|
|
||||||
usePersistentUserChoices,
|
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
|
|
||||||
export type UsernameDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
|
||||||
|
|
||||||
export const UsernameDialog = (props: UsernameDialogProps) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const { saveUsername } = usePersistentUserChoices()
|
|
||||||
|
|
||||||
const ctx = useRoomContext()
|
|
||||||
return (
|
|
||||||
<Dialog title={t('options.username.heading')} {...props}>
|
|
||||||
<Form
|
|
||||||
onSubmit={(data) => {
|
|
||||||
const { username } = data as { username: string }
|
|
||||||
ctx.localParticipant.setName(username)
|
|
||||||
saveUsername(username)
|
|
||||||
const { onOpenChange } = props
|
|
||||||
if (onOpenChange) {
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
submitLabel={t('options.username.submitLabel')}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
label={t('options.username.label')}
|
|
||||||
description={t('options.username.description')}
|
|
||||||
defaultValue={ctx.localParticipant.name}
|
|
||||||
validate={(value) => {
|
|
||||||
return !value ? (
|
|
||||||
<p>{t('options.username.validationError')}</p>
|
|
||||||
) : null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { LocalParticipant, Participant } from 'livekit-client'
|
||||||
|
import { useParticipantInfo } from '@livekit/components-react'
|
||||||
|
import { isLocal } from '@/utils/livekit'
|
||||||
|
|
||||||
|
type useRaisedHandProps = {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||||
|
const { metadata } = useParticipantInfo({ participant })
|
||||||
|
const parsedMetadata = JSON.parse(metadata || '{}')
|
||||||
|
|
||||||
|
const toggleRaisedHand = () => {
|
||||||
|
if (isLocal(participant)) {
|
||||||
|
parsedMetadata.raised = !parsedMetadata.raised
|
||||||
|
const localParticipant = participant as LocalParticipant
|
||||||
|
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isHandRaised: parsedMetadata.raised, toggleRaisedHand }
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
const useLatest = <T>(current: T) => {
|
||||||
|
const storedValue = React.useRef(current)
|
||||||
|
React.useEffect(() => {
|
||||||
|
storedValue.current = current
|
||||||
|
})
|
||||||
|
return storedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
|
||||||
|
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function useResizeObserver<T extends HTMLElement>(
|
||||||
|
target: React.RefObject<T>,
|
||||||
|
callback: UseResizeObserverCallback
|
||||||
|
) {
|
||||||
|
const resizeObserver = getResizeObserver()
|
||||||
|
const storedCallback = useLatest(callback)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
let didUnsubscribe = false
|
||||||
|
|
||||||
|
const targetEl = target.current
|
||||||
|
if (!targetEl) return
|
||||||
|
|
||||||
|
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
|
||||||
|
if (didUnsubscribe) return
|
||||||
|
storedCallback.current(entry, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver?.subscribe(targetEl as HTMLElement, cb)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
didUnsubscribe = true
|
||||||
|
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb)
|
||||||
|
}
|
||||||
|
}, [target.current, resizeObserver, storedCallback])
|
||||||
|
|
||||||
|
return resizeObserver?.observer
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResizeObserver() {
|
||||||
|
let ticking = false
|
||||||
|
let allEntries: ResizeObserverEntry[] = []
|
||||||
|
|
||||||
|
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map()
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(
|
||||||
|
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
|
||||||
|
allEntries = allEntries.concat(entries)
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const triggered = new Set<Element>()
|
||||||
|
for (let i = 0; i < allEntries.length; i++) {
|
||||||
|
if (triggered.has(allEntries[i].target)) continue
|
||||||
|
triggered.add(allEntries[i].target)
|
||||||
|
const cbs = callbacks.get(allEntries[i].target)
|
||||||
|
cbs?.forEach((cb) => cb(allEntries[i], obs))
|
||||||
|
}
|
||||||
|
allEntries = []
|
||||||
|
ticking = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ticking = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
observer,
|
||||||
|
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||||
|
observer.observe(target)
|
||||||
|
const cbs = callbacks.get(target) ?? []
|
||||||
|
cbs.push(callback)
|
||||||
|
callbacks.set(target, cbs)
|
||||||
|
},
|
||||||
|
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||||
|
const cbs = callbacks.get(target) ?? []
|
||||||
|
if (cbs.length === 1) {
|
||||||
|
observer.unobserve(target)
|
||||||
|
callbacks.delete(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cbIndex = cbs.indexOf(callback)
|
||||||
|
if (cbIndex !== -1) cbs.splice(cbIndex, 1)
|
||||||
|
callbacks.set(target, cbs)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _resizeObserver: ReturnType<typeof createResizeObserver>
|
||||||
|
|
||||||
|
const getResizeObserver = () =>
|
||||||
|
!_resizeObserver
|
||||||
|
? (_resizeObserver = createResizeObserver())
|
||||||
|
: _resizeObserver
|
||||||
|
|
||||||
|
export type UseResizeObserverCallback = (
|
||||||
|
entry: ResizeObserverEntry,
|
||||||
|
observer: ResizeObserver
|
||||||
|
) => unknown
|
||||||
|
|
||||||
|
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
|
||||||
|
const [size, setSize] = React.useState({ width: 0, height: 0 })
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (target.current) {
|
||||||
|
const { width, height } = target.current.getBoundingClientRect()
|
||||||
|
setSize({ width, height })
|
||||||
|
}
|
||||||
|
}, [target.current])
|
||||||
|
|
||||||
|
const resizeCallback = React.useCallback(
|
||||||
|
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
// Where the magic happens
|
||||||
|
useResizeObserver(target, resizeCallback)
|
||||||
|
return size
|
||||||
|
}
|
||||||
@@ -0,0 +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, room.localParticipant.name]
|
||||||
|
return queryClient.getQueryData<ApiRoom>(queryKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useLayoutContext } from '@livekit/components-react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { participantsStore } from '@/stores/participants.ts'
|
||||||
|
|
||||||
|
export const useWidgetInteraction = () => {
|
||||||
|
const { dispatch, state } = useLayoutContext().widget
|
||||||
|
|
||||||
|
const participantsSnap = useSnapshot(participantsStore)
|
||||||
|
const isParticipantsOpen = participantsSnap.showParticipants
|
||||||
|
|
||||||
|
const toggleParticipants = () => {
|
||||||
|
if (dispatch && state?.showChat) {
|
||||||
|
dispatch({ msg: 'toggle_chat' })
|
||||||
|
}
|
||||||
|
participantsStore.showParticipants = !isParticipantsOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChat = () => {
|
||||||
|
if (isParticipantsOpen) {
|
||||||
|
participantsStore.showParticipants = false
|
||||||
|
}
|
||||||
|
if (dispatch) {
|
||||||
|
dispatch({ msg: 'toggle_chat' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleParticipants,
|
||||||
|
toggleChat,
|
||||||
|
isChatOpen: state?.showChat,
|
||||||
|
unreadMessages: state?.unreadMessages,
|
||||||
|
isParticipantsOpen,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { OptionsButton } from '../components/controls/Options/OptionsButton'
|
import { OptionsButton } from '../components/controls/Options/OptionsButton'
|
||||||
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
|
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
|
||||||
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
|
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
|
||||||
|
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export type ControlBarControls = {
|
export type ControlBarControls = {
|
||||||
@@ -183,6 +184,7 @@ export function ControlBar({
|
|||||||
)}
|
)}
|
||||||
</TrackToggle>
|
</TrackToggle>
|
||||||
)}
|
)}
|
||||||
|
<HandToggle />
|
||||||
<ChatToggle />
|
<ChatToggle />
|
||||||
<ParticipantsToggle />
|
<ParticipantsToggle />
|
||||||
<OptionsButton />
|
<OptionsButton />
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ import * as React from 'react'
|
|||||||
import {
|
import {
|
||||||
CarouselLayout,
|
CarouselLayout,
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
FocusLayout,
|
|
||||||
FocusLayoutContainer,
|
FocusLayoutContainer,
|
||||||
GridLayout,
|
GridLayout,
|
||||||
LayoutContextProvider,
|
LayoutContextProvider,
|
||||||
ParticipantTile,
|
|
||||||
RoomAudioRenderer,
|
RoomAudioRenderer,
|
||||||
MessageFormatter,
|
MessageFormatter,
|
||||||
usePinnedTracks,
|
usePinnedTracks,
|
||||||
@@ -35,6 +33,10 @@ import { cva } from '@/styled-system/css'
|
|||||||
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
|
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { participantsStore } from '@/stores/participants'
|
import { participantsStore } from '@/stores/participants'
|
||||||
|
import { FocusLayout } from '../components/FocusLayout'
|
||||||
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
|
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||||
|
import { RecordingIndicator } from '@/features/rooms/livekit/components/RecordingIndicator.tsx'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -183,26 +185,40 @@ export function VideoConference({
|
|||||||
onWidgetChange={widgetUpdate}
|
onWidgetChange={widgetUpdate}
|
||||||
>
|
>
|
||||||
<div className="lk-video-conference-inner">
|
<div className="lk-video-conference-inner">
|
||||||
|
<RecordingIndicator />
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
{!focusTrack ? (
|
<div
|
||||||
<div
|
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
||||||
className="lk-grid-layout-wrapper"
|
>
|
||||||
style={{ height: 'auto' }}
|
{!focusTrack ? (
|
||||||
>
|
<div
|
||||||
<GridLayout tracks={tracks}>
|
className="lk-grid-layout-wrapper"
|
||||||
<ParticipantTile />
|
style={{ height: 'auto' }}
|
||||||
</GridLayout>
|
>
|
||||||
</div>
|
<GridLayout tracks={tracks}>
|
||||||
) : (
|
|
||||||
<div className="lk-focus-layout-wrapper">
|
|
||||||
<FocusLayoutContainer>
|
|
||||||
<CarouselLayout tracks={carouselTracks}>
|
|
||||||
<ParticipantTile />
|
<ParticipantTile />
|
||||||
</CarouselLayout>
|
</GridLayout>
|
||||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
</div>
|
||||||
</FocusLayoutContainer>
|
) : (
|
||||||
</div>
|
<div
|
||||||
)}
|
className="lk-focus-layout-wrapper"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
<FocusLayoutContainer>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MainNotificationToast />
|
||||||
|
</div>
|
||||||
<Chat
|
<Chat
|
||||||
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
|
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
|
||||||
messageFormatter={chatMessageFormatter}
|
messageFormatter={chatMessageFormatter}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
export const getParticipantColor = (
|
||||||
|
participant: Participant
|
||||||
|
): undefined | string => {
|
||||||
|
const { metadata } = participant
|
||||||
|
if (!metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return JSON.parse(metadata)['color']
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Dialog, type DialogProps } from '@/primitives'
|
||||||
|
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { text } from '@/primitives/Text.tsx'
|
||||||
|
import { Heading } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RiAccountCircleLine,
|
||||||
|
RiSettings3Line,
|
||||||
|
RiSpeakerLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { AccountTab } from './tabs/AccountTab'
|
||||||
|
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
|
||||||
|
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
|
||||||
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
const tabsStyle = css({
|
||||||
|
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||||
|
width: '50rem', // fixme size copied from meet settings modal
|
||||||
|
marginY: '-1rem', // fixme hacky solution to cancel modal padding
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: 'calc(100vh - 2rem)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabListContainerStyle = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRight: '1px solid lightGray', // fixme poor color management
|
||||||
|
paddingY: '1rem',
|
||||||
|
paddingRight: '1.5rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabPanelContainerStyle = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: '1',
|
||||||
|
marginTop: '3.5rem',
|
||||||
|
minWidth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SettingsDialogExtended = Pick<
|
||||||
|
DialogProps,
|
||||||
|
'isOpen' | 'onOpenChange'
|
||||||
|
>
|
||||||
|
|
||||||
|
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||||
|
// display only icon on small screen
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
|
||||||
|
const dialogEl = useRef<HTMLDivElement>(null)
|
||||||
|
const { width } = useSize(dialogEl)
|
||||||
|
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||||
|
<Tabs orientation="vertical" className={tabsStyle}>
|
||||||
|
<div
|
||||||
|
className={tabListContainerStyle}
|
||||||
|
style={{
|
||||||
|
flex: isWideScreen ? '0 0 16rem' : undefined,
|
||||||
|
paddingTop: !isWideScreen ? '64px' : undefined,
|
||||||
|
paddingRight: !isWideScreen ? '1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isWideScreen && (
|
||||||
|
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
|
||||||
|
{t('dialog.heading')}
|
||||||
|
</Heading>
|
||||||
|
)}
|
||||||
|
<TabList border={false} aria-label="Chat log orientation example">
|
||||||
|
<Tab icon highlight id="1">
|
||||||
|
<RiAccountCircleLine />
|
||||||
|
{isWideScreen && t('tabs.account')}
|
||||||
|
</Tab>
|
||||||
|
<Tab icon highlight id="2">
|
||||||
|
<RiSpeakerLine />
|
||||||
|
{isWideScreen && t('tabs.audio')}
|
||||||
|
</Tab>
|
||||||
|
<Tab icon highlight id="3">
|
||||||
|
<RiSettings3Line />
|
||||||
|
{isWideScreen && t('tabs.general')}
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
</div>
|
||||||
|
<div className={tabPanelContainerStyle}>
|
||||||
|
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
||||||
|
<AudioTab id="2" />
|
||||||
|
<GeneralTab id="3" />
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
usePersistentUserChoices,
|
||||||
|
useRoomContext,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { authUrl, 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'
|
||||||
|
|
||||||
|
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
|
Pick<TabPanelProps, 'id'>
|
||||||
|
|
||||||
|
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const { saveUsername } = usePersistentUserChoices()
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { user, isLoggedIn } = useUser()
|
||||||
|
const [name, setName] = useState(room?.localParticipant.name || '')
|
||||||
|
|
||||||
|
const handleOnSubmit = () => {
|
||||||
|
if (room) room.localParticipant.setName(name)
|
||||||
|
saveUsername(name)
|
||||||
|
if (onOpenChange) onOpenChange(false)
|
||||||
|
}
|
||||||
|
const handleOnCancel = () => {
|
||||||
|
if (onOpenChange) onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
label={t('account.nameLabel')}
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
validate={(value) => {
|
||||||
|
return !value ? <p>{'Votre Nom ne peut pas être vide'}</p> : null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<H lvl={2}>{t('account.authentication')}</H>
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<P>
|
||||||
|
<Trans
|
||||||
|
i18nKey="settings:account.currentlyLoggedAs"
|
||||||
|
values={{ user: user?.email }}
|
||||||
|
components={[<Badge />]}
|
||||||
|
/>
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
<A href={logoutUrl()}>{t('logout', { ns: 'global' })}</A>
|
||||||
|
</P>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<P>{t('account.youAreNotLoggedIn')}</P>
|
||||||
|
<P>
|
||||||
|
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
|
||||||
|
</P>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<HStack
|
||||||
|
className={css({
|
||||||
|
marginTop: 'auto',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button onPress={handleOnCancel}>
|
||||||
|
{t('cancel', { ns: 'global' })}
|
||||||
|
</Button>
|
||||||
|
<Button variant={'primary'} onPress={handleOnSubmit}>
|
||||||
|
{t('submit', { ns: 'global' })}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { DialogProps, Field, H } from '@/primitives'
|
||||||
|
|
||||||
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
|
import {
|
||||||
|
useIsSpeaking,
|
||||||
|
useMediaDeviceSelect,
|
||||||
|
useRoomContext,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { isSafari } from '@/utils/livekit'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { SoundTester } from '@/components/SoundTester'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type RowWrapperProps = {
|
||||||
|
heading: string
|
||||||
|
children: ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const RowWrapper = ({ heading, children }: RowWrapperProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H lvl={2}>{heading}</H>
|
||||||
|
<HStack
|
||||||
|
gap={0}
|
||||||
|
style={{
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 215px',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children[0]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '10rem',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
paddingLeft: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children[1]}
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
|
Pick<TabPanelProps, 'id'>
|
||||||
|
|
||||||
|
type DeviceItems = Array<{ value: string; label: string }>
|
||||||
|
|
||||||
|
export const AudioTab = ({ id }: AudioTabProps) => {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const { localParticipant } = useRoomContext()
|
||||||
|
|
||||||
|
const isSpeaking = useIsSpeaking(localParticipant)
|
||||||
|
|
||||||
|
const {
|
||||||
|
devices: devicesOut,
|
||||||
|
activeDeviceId: activeDeviceIdOut,
|
||||||
|
setActiveMediaDevice: setActiveMediaDeviceOut,
|
||||||
|
} = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
|
|
||||||
|
const {
|
||||||
|
devices: devicesIn,
|
||||||
|
activeDeviceId: activeDeviceIdIn,
|
||||||
|
setActiveMediaDevice: setActiveMediaDeviceIn,
|
||||||
|
} = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||||
|
|
||||||
|
const itemsOut: DeviceItems = devicesOut.map((d) => ({
|
||||||
|
value: d.deviceId,
|
||||||
|
label: d.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const itemsIn: DeviceItems = devicesIn.map((d) => ({
|
||||||
|
value: d.deviceId,
|
||||||
|
label: d.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for microphone permissions
|
||||||
|
// may raise an error. As a workaround, we infer microphone permission status by checking if the list of audio input
|
||||||
|
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted microphone access.
|
||||||
|
const isMicEnabled = devicesIn?.length > 0
|
||||||
|
|
||||||
|
const disabledProps = isMicEnabled
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
placeholder: t('audio.permissionsRequired'),
|
||||||
|
isDisabled: true,
|
||||||
|
defaultSelectedKey: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// No API to directly query the default audio device; this function heuristically finds it.
|
||||||
|
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
|
||||||
|
const getDefaultSelectedKey = (items: DeviceItems) => {
|
||||||
|
if (!items || items.length === 0) return
|
||||||
|
const defaultItem =
|
||||||
|
items.find((item) => item.value === 'default') || items[0]
|
||||||
|
return defaultItem.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
|
<RowWrapper heading={t('audio.microphone.heading')}>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('audio.microphone.label')}
|
||||||
|
items={itemsIn}
|
||||||
|
defaultSelectedKey={
|
||||||
|
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
|
||||||
|
}
|
||||||
|
onSelectionChange={(key) => setActiveMediaDeviceIn(key as string)}
|
||||||
|
{...disabledProps}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<>
|
||||||
|
{localParticipant.isMicrophoneEnabled ? (
|
||||||
|
<ActiveSpeaker isSpeaking={isSpeaking} />
|
||||||
|
) : (
|
||||||
|
<span>Micro désactivé</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</RowWrapper>
|
||||||
|
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
|
||||||
|
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
|
||||||
|
{!isSafari() && (
|
||||||
|
<RowWrapper heading={t('audio.speakers.heading')}>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('audio.speakers.label')}
|
||||||
|
items={itemsOut}
|
||||||
|
defaultSelectedKey={
|
||||||
|
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
|
||||||
|
}
|
||||||
|
onSelectionChange={async (key) =>
|
||||||
|
setActiveMediaDeviceOut(key as string)
|
||||||
|
}
|
||||||
|
{...disabledProps}
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SoundTester />
|
||||||
|
</RowWrapper>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Field, H } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
|
|
||||||
|
export type GeneralTabProps = Pick<TabPanelProps, 'id'>
|
||||||
|
|
||||||
|
export const GeneralTab = ({ id }: GeneralTabProps) => {
|
||||||
|
const { t, i18n } = useTranslation('settings')
|
||||||
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
|
<H lvl={2}>{t('language.heading')}</H>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('language.label')}
|
||||||
|
items={languagesList}
|
||||||
|
defaultSelectedKey={currentLanguage.key}
|
||||||
|
onSelectionChange={(lang) => {
|
||||||
|
i18n.changeLanguage(lang as string)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Button, Popover, PopoverList } from '@/primitives'
|
|
||||||
import { useLanguageLabels } from './useLanguageLabels'
|
|
||||||
|
|
||||||
export const LanguageSelector = () => {
|
|
||||||
const { t, i18n } = useTranslation()
|
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
|
||||||
return (
|
|
||||||
<Popover aria-label={t('languageSelector.popoverLabel')}>
|
|
||||||
<Button
|
|
||||||
aria-label={t('languageSelector.buttonLabel', {
|
|
||||||
currentLanguage: currentLanguage.label,
|
|
||||||
})}
|
|
||||||
size="sm"
|
|
||||||
variant="primary"
|
|
||||||
outline
|
|
||||||
>
|
|
||||||
{i18n.language}
|
|
||||||
</Button>
|
|
||||||
<PopoverList
|
|
||||||
items={languagesList}
|
|
||||||
onAction={(lang) => {
|
|
||||||
i18n.changeLanguage(lang)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Div, VerticallyOffCenter } from '@/primitives'
|
import { Div, VerticallyOffCenter } from '@/primitives'
|
||||||
import type { SystemStyleObject } from '../styled-system/types'
|
import type { SystemStyleObject } from '@/styled-system/types'
|
||||||
|
|
||||||
export const Centered = ({
|
export const Centered = ({
|
||||||
width = '38rem',
|
width = '38rem',
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Link } from 'wouter'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Stack } from '@/styled-system/jsx'
|
import { Stack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
|
import { A, Text, Button } from '@/primitives'
|
||||||
import { SettingsButton } from '@/features/settings'
|
import { SettingsButton } from '@/features/settings'
|
||||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||||
import { Feedback } from '@/components/Feedback'
|
import { Feedback } from '@/components/Feedback'
|
||||||
|
import { Menu } from '@/primitives/Menu'
|
||||||
|
import { MenuList } from '@/primitives/MenuList'
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -64,7 +66,7 @@ export const Header = () => {
|
|||||||
<A href={authUrl()}>{t('login')}</A>
|
<A href={authUrl()}>{t('login')}</A>
|
||||||
)}
|
)}
|
||||||
{!!user && (
|
{!!user && (
|
||||||
<Popover aria-label={t('logout')}>
|
<Menu>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
invisible
|
invisible
|
||||||
@@ -73,17 +75,15 @@ export const Header = () => {
|
|||||||
>
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
</Button>
|
</Button>
|
||||||
<PopoverList
|
<MenuList
|
||||||
items={[
|
items={[{ value: 'logout', label: t('logout') }]}
|
||||||
{ key: 'logout', value: 'logout', label: t('logout') },
|
|
||||||
]}
|
|
||||||
onAction={(value) => {
|
onAction={(value) => {
|
||||||
if (value === 'logout') {
|
if (value === 'logout') {
|
||||||
window.location.href = logoutUrl()
|
window.location.href = logoutUrl()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -10,15 +10,12 @@
|
|||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
"languageSelector": {
|
|
||||||
"buttonLabel": "",
|
|
||||||
"popoverLabel": ""
|
|
||||||
},
|
|
||||||
"loading": "",
|
"loading": "",
|
||||||
"loggedInUserTooltip": "",
|
"loggedInUserTooltip": "",
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
"logout": "",
|
"logout": "",
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
}
|
},
|
||||||
|
"submit": "OK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,16 @@
|
|||||||
"joinMeeting": "",
|
"joinMeeting": "",
|
||||||
"joinMeetingTipContent": "",
|
"joinMeetingTipContent": "",
|
||||||
"joinMeetingTipHeading": "",
|
"joinMeetingTipHeading": "",
|
||||||
"loginToCreateMeeting": ""
|
"loginToCreateMeeting": "",
|
||||||
|
"createMenu": {
|
||||||
|
"laterOption": "",
|
||||||
|
"instantOption": ""
|
||||||
|
},
|
||||||
|
"laterMeetingDialog": {
|
||||||
|
"heading": "",
|
||||||
|
"description": "",
|
||||||
|
"copy": "",
|
||||||
|
"copied": "",
|
||||||
|
"permissions": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"defaultName": "",
|
||||||
|
"joined": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"raised": {
|
||||||
|
"description": "",
|
||||||
|
"cta": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "",
|
"leaveRoomPrompt": "",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copied": "",
|
|
||||||
"copy": "",
|
"copy": "",
|
||||||
|
"copied": "",
|
||||||
"heading": "",
|
"heading": "",
|
||||||
"inputLabel": ""
|
"description": "",
|
||||||
|
"permissions": ""
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
@@ -32,6 +33,10 @@
|
|||||||
"open": "",
|
"open": "",
|
||||||
"closed": ""
|
"closed": ""
|
||||||
},
|
},
|
||||||
|
"hand": {
|
||||||
|
"raise": "",
|
||||||
|
"lower": ""
|
||||||
|
},
|
||||||
"leave": "",
|
"leave": "",
|
||||||
"participants": {
|
"participants": {
|
||||||
"open": "",
|
"open": "",
|
||||||
@@ -45,18 +50,27 @@
|
|||||||
"support": "",
|
"support": "",
|
||||||
"settings": "",
|
"settings": "",
|
||||||
"username": ""
|
"username": ""
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"heading": "",
|
|
||||||
"label": "",
|
|
||||||
"description": "",
|
|
||||||
"validationError": "",
|
|
||||||
"submitLabel": ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"heading": "",
|
"heading": "",
|
||||||
|
"subheading": "",
|
||||||
"closeButton": "",
|
"closeButton": "",
|
||||||
"you": ""
|
"contributors": "",
|
||||||
|
"collapsable": {
|
||||||
|
"open": "",
|
||||||
|
"close": ""
|
||||||
|
},
|
||||||
|
"you": "",
|
||||||
|
"muteYourself": "",
|
||||||
|
"muteParticipant": "",
|
||||||
|
"muteParticipantAlert": {
|
||||||
|
"description": "",
|
||||||
|
"confirm": "",
|
||||||
|
"cancel": ""
|
||||||
|
},
|
||||||
|
"raisedHands": "",
|
||||||
|
"lowerParticipantHand": "",
|
||||||
|
"lowerParticipantsHand": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,22 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"currentlyLoggedAs": "",
|
"currentlyLoggedAs": "",
|
||||||
"heading": "",
|
"heading": "",
|
||||||
"youAreNotLoggedIn": ""
|
"youAreNotLoggedIn": "",
|
||||||
|
"nameLabel": "",
|
||||||
|
"authentication": ""
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"microphone": {
|
||||||
|
"heading": "",
|
||||||
|
"label": ""
|
||||||
|
},
|
||||||
|
"speakers": {
|
||||||
|
"heading": "",
|
||||||
|
"label": "",
|
||||||
|
"test": "",
|
||||||
|
"ongoingTest": ""
|
||||||
|
},
|
||||||
|
"permissionsRequired": ""
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
@@ -11,5 +26,10 @@
|
|||||||
"heading": "",
|
"heading": "",
|
||||||
"label": ""
|
"label": ""
|
||||||
},
|
},
|
||||||
"settingsButtonLabel": ""
|
"settingsButtonLabel": "",
|
||||||
|
"tabs": {
|
||||||
|
"account": "",
|
||||||
|
"audio": "",
|
||||||
|
"general": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,12 @@
|
|||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": "You don't have the permission to view this page"
|
"heading": "You don't have the permission to view this page"
|
||||||
},
|
},
|
||||||
"languageSelector": {
|
|
||||||
"buttonLabel": "Change language (currently {{currentLanguage}})",
|
|
||||||
"popoverLabel": "Choose language"
|
|
||||||
},
|
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"loggedInUserTooltip": "Logged in as…",
|
"loggedInUserTooltip": "Logged in as…",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"heading": "Page not found"
|
"heading": "Page not found"
|
||||||
}
|
},
|
||||||
|
"submit": "OK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,16 @@
|
|||||||
"joinMeeting": "Join a meeting",
|
"joinMeeting": "Join a meeting",
|
||||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||||
"joinMeetingTipHeading": "Did you know?",
|
"joinMeetingTipHeading": "Did you know?",
|
||||||
"loginToCreateMeeting": "Login to create a meeting"
|
"loginToCreateMeeting": "Login to create a meeting",
|
||||||
|
"createMenu": {
|
||||||
|
"laterOption": "Create a meeting for a later date",
|
||||||
|
"instantOption": "Start an instant meeting"
|
||||||
|
},
|
||||||
|
"laterMeetingDialog": {
|
||||||
|
"heading": "Your connection details",
|
||||||
|
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without Agent Connect.",
|
||||||
|
"copy": "Copy the meeting link",
|
||||||
|
"copied": "Link copied to clipboard",
|
||||||
|
"permissions": "People with this link do not need your permission to join this meeting."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"defaultName": "A contributor",
|
||||||
|
"joined": {
|
||||||
|
"description": "{{name}} has joined the room"
|
||||||
|
},
|
||||||
|
"raised": {
|
||||||
|
"description": "{{name}} has raised their hand.",
|
||||||
|
"cta": "Open waiting list"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copied": "Copied",
|
"copy": "Copy the meeting link",
|
||||||
"copy": "Copy",
|
"copied": "Link copied to clipboard",
|
||||||
"heading": "Share the meeting link",
|
"heading": "Your meeting is ready",
|
||||||
"inputLabel": "Meeting link"
|
"description": "Share this link with people you want to invite to the meeting.",
|
||||||
|
"permissions": "People with this link do not need your permission to join this meeting."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
@@ -32,6 +33,10 @@
|
|||||||
"open": "Close the chat",
|
"open": "Close the chat",
|
||||||
"closed": "Open the chat"
|
"closed": "Open the chat"
|
||||||
},
|
},
|
||||||
|
"hand": {
|
||||||
|
"raise": "Raise hand",
|
||||||
|
"lower": "Lower hand"
|
||||||
|
},
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"participants": {
|
"participants": {
|
||||||
"open": "Hide everyone",
|
"open": "Hide everyone",
|
||||||
@@ -45,18 +50,27 @@
|
|||||||
"support": "Get Help on Tchap",
|
"support": "Get Help on Tchap",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"username": "Update Your Name"
|
"username": "Update Your Name"
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"heading": "Update Your Name",
|
|
||||||
"label": "Your Name",
|
|
||||||
"description": "All other participants will see this name.",
|
|
||||||
"validationError": "Name cannot be empty.",
|
|
||||||
"submitLabel": "Save"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"heading": "Participants",
|
"heading": "Participants",
|
||||||
|
"subheading": "In room",
|
||||||
"closeButton": "Hide participants",
|
"closeButton": "Hide participants",
|
||||||
"you": "You"
|
"you": "You",
|
||||||
|
"contributors": "Contributors",
|
||||||
|
"collapsable": {
|
||||||
|
"open": "Open {{name}} list",
|
||||||
|
"close": "Close {{name}} list"
|
||||||
|
},
|
||||||
|
"muteYourself": "Close your mic",
|
||||||
|
"muteParticipant": "Close the mic of {{name}}",
|
||||||
|
"muteParticipantAlert": {
|
||||||
|
"description": "Mute {{name}} for all participants? {{name}} will be the only one who can unmute themselves.",
|
||||||
|
"confirm": "Mute",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"raisedHands": "Raised hands",
|
||||||
|
"lowerParticipantHand": "Lower {{name}}'s hand",
|
||||||
|
"lowerParticipantsHand": "Lower all hands"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,22 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
|
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
|
||||||
"heading": "Account",
|
"heading": "Account",
|
||||||
"youAreNotLoggedIn": "You are not logged in."
|
"youAreNotLoggedIn": "You are not logged in.",
|
||||||
|
"nameLabel": "Votre Nom",
|
||||||
|
"authentication": "Authentication"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"microphone": {
|
||||||
|
"heading": "Microphone",
|
||||||
|
"label": "Select your audio input"
|
||||||
|
},
|
||||||
|
"speakers": {
|
||||||
|
"heading": "Speakers",
|
||||||
|
"label": "Select your audio output",
|
||||||
|
"test": "Test",
|
||||||
|
"ongoingTest": "Testing sound…"
|
||||||
|
},
|
||||||
|
"permissionsRequired": "Permissions required"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": "Settings"
|
"heading": "Settings"
|
||||||
@@ -11,5 +26,10 @@
|
|||||||
"heading": "Language",
|
"heading": "Language",
|
||||||
"label": "Language"
|
"label": "Language"
|
||||||
},
|
},
|
||||||
"settingsButtonLabel": "Settings"
|
"settingsButtonLabel": "Settings",
|
||||||
|
"tabs": {
|
||||||
|
"account": "Profile",
|
||||||
|
"audio": "Audio",
|
||||||
|
"general": "General"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,12 @@
|
|||||||
"forbidden": {
|
"forbidden": {
|
||||||
"heading": "Accès interdit"
|
"heading": "Accès interdit"
|
||||||
},
|
},
|
||||||
"languageSelector": {
|
|
||||||
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})",
|
|
||||||
"popoverLabel": "Choix de la langue"
|
|
||||||
},
|
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"loggedInUserTooltip": "Connecté en tant que…",
|
"loggedInUserTooltip": "Connecté en tant que…",
|
||||||
"login": "Se connecter",
|
"login": "Se connecter",
|
||||||
"logout": "Se déconnecter",
|
"logout": "Se déconnecter",
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"heading": "Page introuvable"
|
"heading": "Page introuvable"
|
||||||
}
|
},
|
||||||
|
"submit": "OK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,16 @@
|
|||||||
"joinMeeting": "Rejoindre une réunion",
|
"joinMeeting": "Rejoindre une réunion",
|
||||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||||
"joinMeetingTipHeading": "Astuce",
|
"joinMeetingTipHeading": "Astuce",
|
||||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion"
|
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||||
|
"createMenu": {
|
||||||
|
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||||
|
"instantOption": "Démarrer une réunion instantanée"
|
||||||
|
},
|
||||||
|
"laterMeetingDialog": {
|
||||||
|
"heading": "Vos informations de connexion",
|
||||||
|
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans Agent Connect.",
|
||||||
|
"copy": "Copier le lien de la réunion",
|
||||||
|
"copied": "Lien copié dans le presse-papiers",
|
||||||
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"defaultName": "Un contributeur",
|
||||||
|
"joined": {
|
||||||
|
"description": "{{name}} participe à l'appel"
|
||||||
|
},
|
||||||
|
"raised": {
|
||||||
|
"description": "{{name}} a levé la main.",
|
||||||
|
"cta": "Ouvrir la file d'attente"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copied": "Lien copié",
|
"copy": "Copier le lien de la réunion",
|
||||||
"copy": "Copier le lien",
|
"copied": "Lien copié dans le presse-papiers",
|
||||||
"heading": "Partager le lien vers la réunion",
|
"heading": "Votre réunion est prête",
|
||||||
"inputLabel": "Lien vers la réunion"
|
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
|
||||||
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
@@ -32,6 +33,10 @@
|
|||||||
"open": "Masquer le chat",
|
"open": "Masquer le chat",
|
||||||
"closed": "Afficher le chat"
|
"closed": "Afficher le chat"
|
||||||
},
|
},
|
||||||
|
"hand": {
|
||||||
|
"raise": "Lever la main",
|
||||||
|
"lower": "Baisser la main"
|
||||||
|
},
|
||||||
"leave": "Quitter",
|
"leave": "Quitter",
|
||||||
"participants": {
|
"participants": {
|
||||||
"open": "Masquer les participants",
|
"open": "Masquer les participants",
|
||||||
@@ -45,18 +50,27 @@
|
|||||||
"support": "Obtenir de l'aide sur Tchap",
|
"support": "Obtenir de l'aide sur Tchap",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"username": "Choisir votre nom"
|
"username": "Choisir votre nom"
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"heading": "Choisir votre nom",
|
|
||||||
"label": "Votre Nom",
|
|
||||||
"description": "Tous les autres participants verront ce nom.",
|
|
||||||
"validationError": "Le nom ne peut pas être vide.",
|
|
||||||
"submitLabel": "Enregistrer"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"heading": "Participants",
|
"heading": "Participants",
|
||||||
|
"subheading": "Dans la réunion",
|
||||||
"closeButton": "Masquer les participants",
|
"closeButton": "Masquer les participants",
|
||||||
"you": "Vous"
|
"you": "Vous",
|
||||||
|
"contributors": "Contributeurs",
|
||||||
|
"collapsable": {
|
||||||
|
"open": "Ouvrir la liste {{name}}",
|
||||||
|
"close": "Fermer la liste {{name}}"
|
||||||
|
},
|
||||||
|
"muteYourself": "Couper votre micro",
|
||||||
|
"muteParticipant": "Couper le micro de {{name}}",
|
||||||
|
"muteParticipantAlert": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"raisedHands": "Mains levées",
|
||||||
|
"lowerParticipantHand": "Baisser la main de {{name}}",
|
||||||
|
"lowerParticipantsHand": "Baisser la main de tous les participants"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,22 @@
|
|||||||
"account": {
|
"account": {
|
||||||
"currentlyLoggedAs": "Vous êtes actuellement connecté en tant que <0>{{user}}</0>",
|
"currentlyLoggedAs": "Vous êtes actuellement connecté en tant que <0>{{user}}</0>",
|
||||||
"heading": "Compte",
|
"heading": "Compte",
|
||||||
"youAreNotLoggedIn": "Vous n'êtes pas connecté."
|
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
|
||||||
|
"nameLabel": "Votre Nom",
|
||||||
|
"authentication": "Authentification"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"microphone": {
|
||||||
|
"heading": "Micro",
|
||||||
|
"label": "Sélectionner votre entrée audio"
|
||||||
|
},
|
||||||
|
"speakers": {
|
||||||
|
"heading": "Haut-parleurs",
|
||||||
|
"label": "Sélectionner votre sortie audio",
|
||||||
|
"test": "Tester",
|
||||||
|
"ongoingTest": "Test du son…"
|
||||||
|
},
|
||||||
|
"permissionsRequired": "Autorisations nécessaires"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": "Paramètres"
|
"heading": "Paramètres"
|
||||||
@@ -11,5 +26,10 @@
|
|||||||
"heading": "Langue",
|
"heading": "Langue",
|
||||||
"label": "Langue de l'application"
|
"label": "Langue de l'application"
|
||||||
},
|
},
|
||||||
"settingsButtonLabel": "Paramètres"
|
"settingsButtonLabel": "Paramètres",
|
||||||
|
"tabs": {
|
||||||
|
"account": "Profile",
|
||||||
|
"audio": "Audio",
|
||||||
|
"general": "Général"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cva } from '@/styled-system/css'
|
import { cva } from '@/styled-system/css'
|
||||||
import { styled } from '../styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const box = cva({
|
const box = cva({
|
||||||
base: {
|
base: {
|
||||||
|
|||||||
@@ -1,151 +1,20 @@
|
|||||||
import { type ReactNode } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Button as RACButton,
|
Button as RACButton,
|
||||||
ToggleButton as RACToggleButton,
|
|
||||||
type ButtonProps as RACButtonsProps,
|
type ButtonProps as RACButtonsProps,
|
||||||
TooltipTrigger,
|
|
||||||
Link,
|
Link,
|
||||||
LinkProps,
|
LinkProps,
|
||||||
ToggleButtonProps as RACToggleButtonProps,
|
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { cva, type RecipeVariantProps } from '@/styled-system/css'
|
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||||
import { Tooltip } from './Tooltip'
|
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||||
|
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||||
|
|
||||||
const button = cva({
|
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'background 200ms, outline 200ms, border-color 200ms',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
color: 'colorPalette.text',
|
|
||||||
backgroundColor: 'colorPalette',
|
|
||||||
'&[data-selected]': {
|
|
||||||
background: 'colorPalette.active',
|
|
||||||
},
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'colorPalette.hover',
|
|
||||||
},
|
|
||||||
'&[data-pressed]': {
|
|
||||||
backgroundColor: 'colorPalette.active',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
default: {
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingX: '1',
|
|
||||||
paddingY: '0.625',
|
|
||||||
'--square-padding': '{spacing.0.625}',
|
|
||||||
},
|
|
||||||
sm: {
|
|
||||||
borderRadius: 4,
|
|
||||||
paddingX: '0.5',
|
|
||||||
paddingY: '0.25',
|
|
||||||
'--square-padding': '{spacing.0.25}',
|
|
||||||
},
|
|
||||||
xs: {
|
|
||||||
borderRadius: 4,
|
|
||||||
'--square-padding': '0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
square: {
|
|
||||||
true: {
|
|
||||||
paddingX: 'var(--square-padding)',
|
|
||||||
paddingY: 'var(--square-padding)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
default: {
|
|
||||||
colorPalette: 'control',
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
colorPalette: 'primary',
|
|
||||||
},
|
|
||||||
// @TODO: better handling of colors… this is a mess
|
|
||||||
success: {
|
|
||||||
colorPalette: 'success',
|
|
||||||
borderColor: 'success.300',
|
|
||||||
color: 'success.subtle-text',
|
|
||||||
backgroundColor: 'success.subtle',
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'success.200',
|
|
||||||
},
|
|
||||||
'&[data-pressed]': {
|
|
||||||
backgroundColor: 'success.subtle!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
outline: {
|
|
||||||
true: {
|
|
||||||
color: 'colorPalette',
|
|
||||||
backgroundColor: 'transparent!',
|
|
||||||
borderColor: 'currentcolor!',
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'colorPalette.subtle!',
|
|
||||||
},
|
|
||||||
'&[data-pressed]': {
|
|
||||||
backgroundColor: 'colorPalette.subtle!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invisible: {
|
|
||||||
true: {
|
|
||||||
borderColor: 'none!',
|
|
||||||
backgroundColor: 'none!',
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'none!',
|
|
||||||
borderColor: 'colorPalette.active!',
|
|
||||||
},
|
|
||||||
'&[data-pressed]': {
|
|
||||||
borderColor: 'currentcolor',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fullWidth: {
|
|
||||||
true: {
|
|
||||||
width: 'full',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
legacyStyle: {
|
|
||||||
true: {
|
|
||||||
borderColor: 'gray.400',
|
|
||||||
'&[data-hovered]': {
|
|
||||||
borderColor: 'gray.500',
|
|
||||||
},
|
|
||||||
'&[data-pressed]': {
|
|
||||||
borderColor: 'gray.500',
|
|
||||||
},
|
|
||||||
'&[data-selected]': {
|
|
||||||
background: '#e5e7eb',
|
|
||||||
borderColor: 'gray.400',
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'gray.300',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: 'default',
|
|
||||||
variant: 'default',
|
|
||||||
outline: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type Tooltip = {
|
|
||||||
tooltip?: string
|
|
||||||
tooltipType?: 'instant' | 'delayed'
|
|
||||||
}
|
|
||||||
export type ButtonProps = RecipeVariantProps<typeof button> &
|
|
||||||
RACButtonsProps &
|
RACButtonsProps &
|
||||||
Tooltip & {
|
TooltipWrapperProps
|
||||||
toggle?: boolean
|
|
||||||
isSelected?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
|
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||||
|
LinkProps &
|
||||||
|
TooltipWrapperProps
|
||||||
|
|
||||||
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
|
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
|
||||||
|
|
||||||
@@ -154,22 +23,11 @@ export const Button = ({
|
|||||||
tooltipType = 'instant',
|
tooltipType = 'instant',
|
||||||
...props
|
...props
|
||||||
}: ButtonOrLinkProps) => {
|
}: ButtonOrLinkProps) => {
|
||||||
const [variantProps, componentProps] = button.splitVariantProps(props)
|
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||||
if ((props as LinkButtonProps).href !== undefined) {
|
if ((props as LinkButtonProps).href !== undefined) {
|
||||||
return (
|
return (
|
||||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||||
<Link className={button(variantProps)} {...componentProps} />
|
<Link className={buttonRecipe(variantProps)} {...componentProps} />
|
||||||
</TooltipWrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((props as ButtonProps).toggle) {
|
|
||||||
return (
|
|
||||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
|
||||||
<RACToggleButton
|
|
||||||
className={button(variantProps)}
|
|
||||||
{...(componentProps as RACToggleButtonProps)}
|
|
||||||
/>
|
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -177,26 +35,9 @@ export const Button = ({
|
|||||||
return (
|
return (
|
||||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||||
<RACButton
|
<RACButton
|
||||||
className={button(variantProps)}
|
className={buttonRecipe(variantProps)}
|
||||||
{...(componentProps as RACButtonsProps)}
|
{...(componentProps as RACButtonsProps)}
|
||||||
/>
|
/>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TooltipWrapper = ({
|
|
||||||
tooltip,
|
|
||||||
tooltipType,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
} & Tooltip) => {
|
|
||||||
return tooltip ? (
|
|
||||||
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
|
|
||||||
{children}
|
|
||||||
<Tooltip>{tooltip}</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
|
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
|
||||||
import { text } from './Text'
|
import { text } from './Text'
|
||||||
|
import { MutableRefObject } from 'react'
|
||||||
|
|
||||||
const StyledModalOverlay = styled(ModalOverlay, {
|
const StyledModalOverlay = styled(ModalOverlay, {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,7 +29,7 @@ const StyledModalOverlay = styled(ModalOverlay, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// disabled pointerEvents on the stuff surrouding the overlay is there so that clicking on the overlay to close the modal still works
|
// disabled pointerEvents on the stuff surrounding the overlay is there so that clicking on the overlay to close the modal still works
|
||||||
const StyledModal = styled(Modal, {
|
const StyledModal = styled(Modal, {
|
||||||
base: {
|
base: {
|
||||||
width: 'full',
|
width: 'full',
|
||||||
@@ -48,7 +49,7 @@ const StyledRACDialog = styled(RACDialog, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type DialogProps = RACDialogProps & {
|
export type DialogProps = RACDialogProps & {
|
||||||
title: string
|
title?: string
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
/**
|
/**
|
||||||
* use the Dialog as a controlled component
|
* use the Dialog as a controlled component
|
||||||
@@ -60,6 +61,8 @@ export type DialogProps = RACDialogProps & {
|
|||||||
* after user interaction
|
* after user interaction
|
||||||
*/
|
*/
|
||||||
onOpenChange?: (isOpen: boolean) => void
|
onOpenChange?: (isOpen: boolean) => void
|
||||||
|
type?: 'flex'
|
||||||
|
innerRef?: MutableRefObject<HTMLDivElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dialog = ({
|
export const Dialog = ({
|
||||||
@@ -68,9 +71,11 @@ export const Dialog = ({
|
|||||||
onClose,
|
onClose,
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
innerRef,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: DialogProps) => {
|
}: DialogProps) => {
|
||||||
const isAlert = dialogProps['role'] === 'alertdialog'
|
const isAlert = dialogProps['role'] === 'alertdialog'
|
||||||
|
const boxType = dialogProps['type'] !== 'flex' ? 'dialog' : undefined
|
||||||
return (
|
return (
|
||||||
<StyledModalOverlay
|
<StyledModalOverlay
|
||||||
isKeyboardDismissDisabled={isAlert}
|
isKeyboardDismissDisabled={isAlert}
|
||||||
@@ -89,36 +94,35 @@ export const Dialog = ({
|
|||||||
<StyledRACDialog {...dialogProps}>
|
<StyledRACDialog {...dialogProps}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<VerticallyOffCenter>
|
<VerticallyOffCenter>
|
||||||
<Div
|
<Div margin="auto" width="fit-content" maxWidth="full">
|
||||||
width="fit-content"
|
<Div margin="1rem" pointerEvents="auto">
|
||||||
maxWidth="full"
|
<Box size="sm" type={boxType} ref={innerRef}>
|
||||||
margin="auto"
|
{!!title && (
|
||||||
pointerEvents="auto"
|
<Heading
|
||||||
>
|
slot="title"
|
||||||
<Box size="sm" type="dialog">
|
level={1}
|
||||||
<Heading
|
className={text({ variant: 'h1' })}
|
||||||
slot="title"
|
|
||||||
level={1}
|
|
||||||
className={text({ variant: 'h1' })}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Heading>
|
|
||||||
{typeof children === 'function'
|
|
||||||
? children({ close })
|
|
||||||
: children}
|
|
||||||
{!isAlert && (
|
|
||||||
<Div position="absolute" top="5" right="5">
|
|
||||||
<Button
|
|
||||||
invisible
|
|
||||||
size="xs"
|
|
||||||
onPress={() => close()}
|
|
||||||
aria-label={t('closeDialog')}
|
|
||||||
>
|
>
|
||||||
<RiCloseLine />
|
{title}
|
||||||
</Button>
|
</Heading>
|
||||||
</Div>
|
)}
|
||||||
)}
|
{typeof children === 'function'
|
||||||
</Box>
|
? children({ close })
|
||||||
|
: children}
|
||||||
|
{!isAlert && (
|
||||||
|
<Div position="absolute" top="5" right="5">
|
||||||
|
<Button
|
||||||
|
invisible
|
||||||
|
size="xs"
|
||||||
|
onPress={() => close()}
|
||||||
|
aria-label={t('closeDialog')}
|
||||||
|
>
|
||||||
|
<RiCloseLine />
|
||||||
|
</Button>
|
||||||
|
</Div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Div>
|
||||||
</Div>
|
</Div>
|
||||||
</VerticallyOffCenter>
|
</VerticallyOffCenter>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Div } from './Div'
|
|||||||
const FieldWrapper = styled('div', {
|
const FieldWrapper = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
marginBottom: 'textfield',
|
marginBottom: 'textfield',
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { MenuTrigger } from 'react-aria-components'
|
||||||
|
import { StyledPopover } from './Popover'
|
||||||
|
import { Box } from './Box'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
||||||
|
*/
|
||||||
|
export const Menu = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: [trigger: ReactNode, menu: ReactNode]
|
||||||
|
}) => {
|
||||||
|
const [trigger, menu] = children
|
||||||
|
return (
|
||||||
|
<MenuTrigger>
|
||||||
|
{trigger}
|
||||||
|
<StyledPopover>
|
||||||
|
<Box size="sm" type="popover">
|
||||||
|
{menu}
|
||||||
|
</Box>
|
||||||
|
</StyledPopover>
|
||||||
|
</MenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
|
||||||
|
import { menuItemRecipe } from './menuItemRecipe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* render a Button primitive that shows a popover showing a list of pressable items
|
||||||
|
*/
|
||||||
|
export const MenuList = <T extends string | number = string>({
|
||||||
|
onAction,
|
||||||
|
selectedItem,
|
||||||
|
items = [],
|
||||||
|
...menuProps
|
||||||
|
}: {
|
||||||
|
onAction: (key: T) => void
|
||||||
|
selectedItem?: T
|
||||||
|
items: Array<string | { value: T; label: ReactNode }>
|
||||||
|
} & MenuProps<unknown>) => {
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
selectionMode={selectedItem !== undefined ? 'single' : undefined}
|
||||||
|
selectedKeys={selectedItem !== undefined ? [selectedItem] : undefined}
|
||||||
|
{...menuProps}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const value = typeof item === 'string' ? item : item.value
|
||||||
|
const label = typeof item === 'string' ? item : item.label
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
className={menuItemRecipe()}
|
||||||
|
key={value}
|
||||||
|
id={value as string}
|
||||||
|
onAction={() => {
|
||||||
|
onAction(value as T)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
|
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
|
||||||
|
*
|
||||||
|
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
||||||
|
* This is here when needing to show unrestricted content in a box.
|
||||||
*/
|
*/
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { ReactNode, useContext } from 'react'
|
|
||||||
import {
|
|
||||||
ButtonProps,
|
|
||||||
Button,
|
|
||||||
OverlayTriggerStateContext,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const ListItem = styled(Button, {
|
|
||||||
base: {
|
|
||||||
paddingY: 0.125,
|
|
||||||
paddingX: 0.5,
|
|
||||||
textAlign: 'left',
|
|
||||||
width: 'full',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'box.text',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
'&[data-selected]': {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
'&[data-focused]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
'&[data-hovered]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* render a Button primitive that shows a popover showing a list of pressable items
|
|
||||||
*/
|
|
||||||
export const PopoverList = <T extends string | number = string>({
|
|
||||||
onAction,
|
|
||||||
closeOnAction = true,
|
|
||||||
items = [],
|
|
||||||
}: {
|
|
||||||
closeOnAction?: boolean
|
|
||||||
onAction: (key: T) => void
|
|
||||||
items: Array<string | { key: string; value: T; label: ReactNode }>
|
|
||||||
} & ButtonProps) => {
|
|
||||||
const popoverState = useContext(OverlayTriggerStateContext)!
|
|
||||||
return (
|
|
||||||
<ul>
|
|
||||||
{items.map((item) => {
|
|
||||||
const value = typeof item === 'string' ? item : item.value
|
|
||||||
const label = typeof item === 'string' ? item : item.label
|
|
||||||
const key = typeof item === 'string' ? item : item.key
|
|
||||||
return (
|
|
||||||
<li key={key}>
|
|
||||||
<ListItem
|
|
||||||
key={value}
|
|
||||||
onPress={() => {
|
|
||||||
onAction(value as T)
|
|
||||||
if (closeOnAction) {
|
|
||||||
popoverState.close()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</ListItem>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { Box } from './Box'
|
import { Box } from './Box'
|
||||||
import { StyledPopover } from './Popover'
|
import { StyledPopover } from './Popover'
|
||||||
|
import { menuItemRecipe } from './menuItemRecipe'
|
||||||
|
|
||||||
const StyledButton = styled(Button, {
|
const StyledButton = styled(Button, {
|
||||||
base: {
|
base: {
|
||||||
@@ -31,11 +32,20 @@ const StyledButton = styled(Button, {
|
|||||||
'&[data-pressed]': {
|
'&[data-pressed]': {
|
||||||
backgroundColor: 'control.hover',
|
backgroundColor: 'control.hover',
|
||||||
},
|
},
|
||||||
|
// fixme disabled style is being overridden by placeholder one and needs refinement.
|
||||||
|
'&[data-disabled]': {
|
||||||
|
color: 'default.subtle-text',
|
||||||
|
borderColor: 'gray.200',
|
||||||
|
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledSelectValue = styled(SelectValue, {
|
const StyledSelectValue = styled(SelectValue, {
|
||||||
base: {
|
base: {
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textWrap: 'nowrap',
|
||||||
'&[data-placeholder]': {
|
'&[data-placeholder]': {
|
||||||
color: 'default.subtle-text',
|
color: 'default.subtle-text',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
@@ -43,27 +53,6 @@ const StyledSelectValue = styled(SelectValue, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledListBoxItem = styled(ListBoxItem, {
|
|
||||||
base: {
|
|
||||||
paddingY: 0.125,
|
|
||||||
paddingX: 0.5,
|
|
||||||
textAlign: 'left',
|
|
||||||
width: 'full',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'box.text',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
'&[data-selected]': {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
'&[data-focused]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Select = <T extends string | number>({
|
export const Select = <T extends string | number>({
|
||||||
label,
|
label,
|
||||||
items,
|
items,
|
||||||
@@ -85,9 +74,13 @@ export const Select = <T extends string | number>({
|
|||||||
<Box size="sm" type="popover" variant="control">
|
<Box size="sm" type="popover" variant="control">
|
||||||
<ListBox>
|
<ListBox>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<StyledListBoxItem id={item.value} key={item.value}>
|
<ListBoxItem
|
||||||
|
className={menuItemRecipe({ extraPadding: true })}
|
||||||
|
id={item.value}
|
||||||
|
key={item.value}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</StyledListBoxItem>
|
</ListBoxItem>
|
||||||
))}
|
))}
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
Tabs as RACTabs,
|
||||||
|
Tab as RACTab,
|
||||||
|
TabPanel as RACTabPanel,
|
||||||
|
TabList as RACTabList,
|
||||||
|
TabProps as RACTabProps,
|
||||||
|
TabsProps as RACTabsProps,
|
||||||
|
TabListProps as RACTabListProps,
|
||||||
|
TabPanelProps as RACTabPanelProps,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { StyledVariantProps } from '@/styled-system/types'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const StyledTabs = styled(RACTabs, {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
color: 'colorPalette.text',
|
||||||
|
'&[data-orientation=horizontal]': {
|
||||||
|
flexDirection: 'column',
|
||||||
|
'--horizontal': '3px',
|
||||||
|
},
|
||||||
|
'&[data-orientation=vertical]': {
|
||||||
|
flexDirection: 'row',
|
||||||
|
'--vertical': '3px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TabsProps = RACTabsProps & StyledVariantProps<typeof StyledTabs>
|
||||||
|
|
||||||
|
export const Tabs = ({ children, ...props }: TabsProps) => {
|
||||||
|
return <StyledTabs {...props}>{children}</StyledTabs>
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledTab = styled(RACTab, {
|
||||||
|
base: {
|
||||||
|
padding: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
color: 'box.text',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
forcedColorAdjust: 'none',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
border: {
|
||||||
|
true: {
|
||||||
|
'&[data-selected]': {
|
||||||
|
borderColor: 'primary',
|
||||||
|
},
|
||||||
|
borderBottom: 'var(--horizontal) solid',
|
||||||
|
borderInlineEnd: 'var(--vertical) solid',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
true: {
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'colorPalette.active',
|
||||||
|
transition: 'background 200ms, color 200ms',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
color: 'box.text',
|
||||||
|
},
|
||||||
|
'&[data-selected]': {
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
color: 'white',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
true: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
border: true,
|
||||||
|
icon: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TabProps = RACTabProps & StyledVariantProps<typeof StyledTab>
|
||||||
|
|
||||||
|
export const Tab = ({ children, ...props }: TabProps) => {
|
||||||
|
const parentBorder = React.useContext(BorderContext)
|
||||||
|
return (
|
||||||
|
<StyledTab border={parentBorder} {...props}>
|
||||||
|
{children}
|
||||||
|
</StyledTab>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledTabList = styled(RACTabList, {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
'&[data-orientation=vertical]': {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
border: {
|
||||||
|
false: {
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
true: {
|
||||||
|
'&[data-orientation=horizontal]': {
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'gray.300',
|
||||||
|
},
|
||||||
|
'&[data-orientation=vertical]': {
|
||||||
|
borderInlineEnd: '1px solid',
|
||||||
|
borderColor: 'gray.300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
border: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// @fixme type could be simplified to avoid redefining the children props
|
||||||
|
export type TabListProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
} & RACTabListProps<typeof Tab> &
|
||||||
|
StyledVariantProps<typeof StyledTabList>
|
||||||
|
|
||||||
|
const BorderContext = React.createContext<boolean | undefined>(true)
|
||||||
|
|
||||||
|
export const TabList = ({ children, border, ...props }: TabListProps) => {
|
||||||
|
return (
|
||||||
|
<BorderContext.Provider value={border}>
|
||||||
|
<StyledTabList {...props} border={border}>
|
||||||
|
{children}
|
||||||
|
</StyledTabList>
|
||||||
|
</BorderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledTabPanel = styled(RACTabPanel, {
|
||||||
|
base: {
|
||||||
|
marginTop: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
outline: 'none',
|
||||||
|
'&[data-focus-visible]': {
|
||||||
|
outline: '2px solid red',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
flex: {
|
||||||
|
true: {
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
flexDirection: 'column', //fixme should be parameterizable
|
||||||
|
},
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
sm: {
|
||||||
|
padding: '10px',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
padding: '2rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
padding: 'sm',
|
||||||
|
flex: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TabPanelProps = RACTabPanelProps &
|
||||||
|
StyledVariantProps<typeof StyledTabPanel>
|
||||||
|
|
||||||
|
export const TabPanel = ({ children, ...props }: TabPanelProps) => {
|
||||||
|
return <StyledTabPanel {...props}>{children}</StyledTabPanel>
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react'
|
import type { HTMLAttributes } from 'react'
|
||||||
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
|
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
ToggleButton as RACToggleButton,
|
||||||
|
ToggleButtonProps,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
|
||||||
|
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React aria ToggleButton with our button styles, that can take a tooltip if needed
|
||||||
|
*/
|
||||||
|
export const ToggleButton = ({
|
||||||
|
tooltip,
|
||||||
|
tooltipType,
|
||||||
|
...props
|
||||||
|
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
|
||||||
|
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||||
|
<RACToggleButton
|
||||||
|
{...componentProps}
|
||||||
|
className={buttonRecipe(variantProps)}
|
||||||
|
/>
|
||||||
|
</TooltipWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
+30
-5
@@ -2,16 +2,41 @@ import { type ReactNode } from 'react'
|
|||||||
import {
|
import {
|
||||||
OverlayArrow,
|
OverlayArrow,
|
||||||
Tooltip as RACTooltip,
|
Tooltip as RACTooltip,
|
||||||
TooltipProps,
|
TooltipTrigger,
|
||||||
|
type TooltipProps,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
export type TooltipWrapperProps = {
|
||||||
|
tooltip?: string
|
||||||
|
tooltipType?: 'instant' | 'delayed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a component you want to apply a tooltip on (for example a Button)
|
||||||
|
*
|
||||||
|
* If no tooltip is given, just returns children
|
||||||
|
*/
|
||||||
|
export const TooltipWrapper = ({
|
||||||
|
tooltip,
|
||||||
|
tooltipType,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
} & TooltipWrapperProps) => {
|
||||||
|
return tooltip ? (
|
||||||
|
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
||||||
|
{children}
|
||||||
|
<Tooltip>{tooltip}</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Styled react aria Tooltip component.
|
* Styled react aria Tooltip component.
|
||||||
*
|
*
|
||||||
* Note that tooltips are directly handled by Buttons via the `tooltip` prop,
|
|
||||||
* so you should not need to use this component directly.
|
|
||||||
*
|
|
||||||
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
||||||
*/
|
*/
|
||||||
const StyledTooltip = styled(RACTooltip, {
|
const StyledTooltip = styled(RACTooltip, {
|
||||||
@@ -80,7 +105,7 @@ const TooltipArrow = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tooltip = ({
|
const Tooltip = ({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
|
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { styled } from '../styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
export const Ul = styled('ul', {
|
export const Ul = styled('ul', {
|
||||||
base: {
|
base: {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { type RecipeVariantProps, cva } from '@/styled-system/css'
|
||||||
|
|
||||||
|
export type ButtonRecipe = typeof buttonRecipe
|
||||||
|
|
||||||
|
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
|
||||||
|
|
||||||
|
export const buttonRecipe = cva({
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'background 200ms, outline 200ms, border-color 200ms',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
color: 'colorPalette.text',
|
||||||
|
backgroundColor: 'colorPalette',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'colorPalette.hover',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
backgroundColor: 'colorPalette.active',
|
||||||
|
},
|
||||||
|
'&[data-selected]': {
|
||||||
|
backgroundColor: 'colorPalette.active',
|
||||||
|
},
|
||||||
|
'&[data-disabled]': {
|
||||||
|
cursor: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
default: {
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingX: '1',
|
||||||
|
paddingY: '0.625',
|
||||||
|
'--square-padding': '{spacing.0.625}',
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingX: '0.5',
|
||||||
|
paddingY: '0.25',
|
||||||
|
'--square-padding': '{spacing.0.25}',
|
||||||
|
},
|
||||||
|
xs: {
|
||||||
|
borderRadius: 4,
|
||||||
|
'--square-padding': '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
square: {
|
||||||
|
true: {
|
||||||
|
paddingX: 'var(--square-padding)',
|
||||||
|
paddingY: 'var(--square-padding)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
default: {
|
||||||
|
colorPalette: 'control',
|
||||||
|
borderColor: 'control.subtle',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
colorPalette: 'primary',
|
||||||
|
},
|
||||||
|
// @TODO: better handling of colors… this is a mess
|
||||||
|
success: {
|
||||||
|
colorPalette: 'success',
|
||||||
|
borderColor: 'success.300',
|
||||||
|
color: 'success.subtle-text',
|
||||||
|
backgroundColor: 'success.subtle',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'success.200',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
backgroundColor: 'success.subtle!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: 'primary',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
background: 'gray.100 !important',
|
||||||
|
color: 'primary !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
colorPalette: 'danger',
|
||||||
|
borderColor: 'danger.600',
|
||||||
|
color: 'danger.subtle-text',
|
||||||
|
backgroundColor: 'danger.subtle',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'danger.200',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
backgroundColor: 'danger.subtle!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
true: {
|
||||||
|
color: 'colorPalette',
|
||||||
|
backgroundColor: 'transparent!',
|
||||||
|
borderColor: 'currentcolor!',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'colorPalette.subtle!',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
backgroundColor: 'colorPalette.subtle!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invisible: {
|
||||||
|
true: {
|
||||||
|
borderColor: 'none!',
|
||||||
|
backgroundColor: 'none!',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'none!',
|
||||||
|
borderColor: 'colorPalette.active!',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
borderColor: 'currentcolor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
true: {
|
||||||
|
width: 'full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// if the button is next to other ones to make a "button group", tell where the button is to handle radius
|
||||||
|
groupPosition: {
|
||||||
|
left: {
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderLeft: 0,
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
borderRadius: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// some toggle buttons make more sense without a "pushed button" style when selected because their content changes to mark the state
|
||||||
|
toggledStyles: {
|
||||||
|
false: {
|
||||||
|
'&[data-selected]': {
|
||||||
|
backgroundColor: 'colorPalette',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legacyStyle: {
|
||||||
|
true: {
|
||||||
|
borderColor: 'gray.400',
|
||||||
|
transition: 'border 200ms, background 200ms, color 200ms',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
borderColor: 'gray.500',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
borderColor: 'gray.500',
|
||||||
|
},
|
||||||
|
'&[data-selected]': {
|
||||||
|
backgroundColor: '#1d4ed8',
|
||||||
|
color: 'white',
|
||||||
|
borderColor: 'gray.500',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
borderColor: '#6b7280',
|
||||||
|
backgroundColor: '#1e40af',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'default',
|
||||||
|
variant: 'default',
|
||||||
|
outline: false,
|
||||||
|
toggledStyles: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* exposes all primitives we want to use in other parts of the app.
|
||||||
|
*
|
||||||
|
* It's intended not everything is exported: some primitives are meant as building-blocks
|
||||||
|
* for other primitives and don't have any value being exposed.
|
||||||
|
*/
|
||||||
export { A } from './A'
|
export { A } from './A'
|
||||||
export { Badge } from './Badge'
|
export { Badge } from './Badge'
|
||||||
export { Bold } from './Bold'
|
export { Bold } from './Bold'
|
||||||
@@ -13,9 +19,11 @@ export { Hr } from './Hr'
|
|||||||
export { Italic } from './Italic'
|
export { Italic } from './Italic'
|
||||||
export { Input } from './Input'
|
export { Input } from './Input'
|
||||||
export { Link } from './Link'
|
export { Link } from './Link'
|
||||||
|
export { Menu } from './Menu'
|
||||||
|
export { MenuList } from './MenuList'
|
||||||
export { P } from './P'
|
export { P } from './P'
|
||||||
export { Popover } from './Popover'
|
export { Popover } from './Popover'
|
||||||
export { PopoverList } from './PopoverList'
|
|
||||||
export { Text } from './Text'
|
export { Text } from './Text'
|
||||||
|
export { ToggleButton } from './ToggleButton'
|
||||||
export { Ul } from './Ul'
|
export { Ul } from './Ul'
|
||||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { cva } from '@/styled-system/css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reusable styles for a menu item, select item, etc… to be used with panda `css()` or `styled()`
|
||||||
|
*
|
||||||
|
* these are in their own files because react hot refresh doesn't like exporting stuff
|
||||||
|
* that aren't components in component files
|
||||||
|
*/
|
||||||
|
export const menuItemRecipe = cva({
|
||||||
|
base: {
|
||||||
|
paddingY: 0.125,
|
||||||
|
paddingX: 0.5,
|
||||||
|
textAlign: 'left',
|
||||||
|
width: 'full',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'box.text',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
position: 'relative',
|
||||||
|
'&[data-selected]': {
|
||||||
|
'&::before': {
|
||||||
|
content: '"✓"',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '2px',
|
||||||
|
left: '6px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'&[data-focused]': {
|
||||||
|
color: 'primary.text',
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
outline: 'none!',
|
||||||
|
},
|
||||||
|
'&[data-hovered]': {
|
||||||
|
color: 'primary.text',
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
outline: 'none!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
icon: {
|
||||||
|
true: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
paddingY: '0.4rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraPadding: {
|
||||||
|
true: {
|
||||||
|
paddingLeft: 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
egressId: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const egressStore = proxy<State>({
|
||||||
|
egressId: undefined,
|
||||||
|
})
|
||||||
@@ -1,5 +1,27 @@
|
|||||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
import {
|
||||||
|
getBrowser,
|
||||||
|
LocalParticipant,
|
||||||
|
LogLevel,
|
||||||
|
Participant,
|
||||||
|
setLogLevel,
|
||||||
|
} from 'livekit-client'
|
||||||
|
|
||||||
export const silenceLiveKitLogs = (shouldSilenceLogs: boolean) => {
|
export const silenceLiveKitLogs = (shouldSilenceLogs: boolean) => {
|
||||||
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
|
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFireFox(): boolean {
|
||||||
|
return getBrowser()?.name === 'Firefox'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChromiumBased(): boolean {
|
||||||
|
return getBrowser()?.name === 'Chrome'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafari(): boolean {
|
||||||
|
return getBrowser()?.name === 'Safari'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocal(p: Participant) {
|
||||||
|
return p instanceof LocalParticipant
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.4"
|
tag: "v0.1.5"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
migrateJobAnnotations:
|
migrateJobAnnotations:
|
||||||
@@ -108,7 +108,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-frontend
|
repository: lasuite/meet-frontend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.4"
|
tag: "v0.1.5"
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.4"
|
tag: "v0.1.5"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
migrateJobAnnotations:
|
migrateJobAnnotations:
|
||||||
@@ -109,7 +109,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-frontend
|
repository: lasuite/meet-frontend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.4"
|
tag: "v0.1.5"
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "main"
|
tag: "hackathon"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
migrateJobAnnotations:
|
migrateJobAnnotations:
|
||||||
@@ -92,7 +92,7 @@ backend:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: backend
|
name: backend
|
||||||
key: LIVEKIT_API_KEY
|
key: LIVEKIT_API_KEY
|
||||||
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
|
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
|
||||||
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
|
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
|
||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-frontend
|
repository: lasuite/meet-frontend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "main"
|
tag: "hackathon"
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user