Compare commits

..

64 Commits

Author SHA1 Message Date
lebaudantoine 4c2c7f61d5 🚸(frontend) make explicit why a page is not found
Enhance the error message based on @spaccoud's feedback,
explain to the user why a page is not found.
2025-02-25 20:06:48 +01:00
lebaudantoine d0dea3d7cf 🩹(frontend) make "not found screen" accessible again
With the recent introduction of SDK, the route "not found screen" was broken.
Fixed it.
2025-02-25 20:06:35 +01:00
lebaudantoine 14885e0ffa 🔖(minor) bump release to 0.1.14
Release emoji reactions.
2025-02-24 12:33:52 +01:00
lebaudantoine 054e7ba945 ️(tilt) clean automatically old images
Tilt live updates generate a new image for each change, ending up storing
a lot of images when you are really developing with Tilt.

I have not found a built-in way of cleaning old images from Tilt documentation,
I create a utility doing the dirty work.
2025-02-24 11:05:52 +01:00
lebaudantoine 25506b6a2a 🩹(frontend) fix missing key in reactions toolbar
Emojis are a constant, so the reaction index remains stable,
but using the index as a key isn't ideal.
2025-02-22 17:43:25 +01:00
lebaudantoine 0498a9e7f9 🩹(frontend) preload notifications translations to prevent flickering
The notifications namespace was being lazy-loaded when the first notification
appeared, causing a screen flicker during translation loading. Now preloading
the namespace during i18n initialization to ensure smooth rendering.
2025-02-21 19:19:13 +01:00
lebaudantoine 0a63270154 🚸(frontend) offer a "clear effect" option
Based on user feedbacks, allow them stopping an effect
explicitly by pressing a "no effect" option.
2025-02-21 18:14:17 +01:00
Arnaud Robin b962dddbf2 (frontend) add emoji reactions feature
Implement a new reactions system allowing users
to send quick emoji responses during video calls.
This feature is present in most visio softwares.

Particulary this commit:
- Add ReactionsButton to control bar with emoji selection
- Support floating emoji animations
2025-02-21 13:49:38 +01:00
lebaudantoine 16929bcc83 ♻️(frontend) allow passing placement to a menu
Props supported by react aria. Introduce it.
2025-02-21 13:49:38 +01:00
lebaudantoine 354bdd8bfe 🩹(frontend) fix screen sharing warnings browser compatibility
This feature is far from perfect. Still not working on Safari.
Also, we should display this warning when user share the current
opened window. Minor enhancement, I don't have time currently
to prioritize these enhancements.
2025-02-21 12:14:06 +01:00
Jacques ROUSSEL ccca2b9472 🔧(ci) fix argocd notification
Argocd deployment use numerique-gouv/lasuite-deploiement as source so
the webhook need to tell argocd to refresh apps that use this repos
2025-02-21 11:21:01 +01:00
lebaudantoine 9fc5681137 🩹(frontend) fix visual regression on Ratings buttons
Visual regression was introduced probably by a change on button style.
As this button is quite different than the usual one, remove its inheritance
from the primitive ones.
2025-02-20 19:00:46 +01:00
renovate[bot] edf29076ba ⬆️(dependencies) update vite to v5.4.12 [SECURITY] 2025-02-20 10:31:55 +01:00
lebaudantoine 337181add8 🩹(frontend) fix text overflow in message notification
Minor issue, layout overflow occurred when sharing url or
long text.
2025-02-19 22:47:49 +01:00
lebaudantoine 5af64e539d ♻️(frontend) refactor toast selection to use a switch
More extensible design. Still a poor piece of code.
Technical debt here.
2025-02-19 22:47:03 +01:00
lebaudantoine 291544bd52 (frontend) lower hand autonomously for active speakers
Users requested that their raised hands be lowered automatically if they become
the active speaker. This change ensures a more natural experience during
discussions, preventing user from forgetting to lower their hand while speaking.
2025-02-19 22:47:03 +01:00
lebaudantoine 13bb6acdf6 ♻️(frontend) extract notification duration in a proper file
As for notification's type, extract notification's duration in a
dedicated file.
2025-02-19 22:47:03 +01:00
lebaudantoine aa2783ae2e 🚸(frontend) auto-hide participant controls after 3s of inactivity
Auto-hides participant tile controls after 3 seconds of cursor inactivity
to reduce visual clutter. Controls reappear on cursor movement.
2025-02-19 22:37:48 +01:00
Nathan Vasse c3e4ea0fd1 🔧(ci) add sdk to workflow
Add basic jobs to handle the new sdk.
2025-02-18 16:16:07 +01:00
Nathan Vasse 183e8f6a72 (sdk) add sdk projects
It includes a consumer project which is simply a demo app. The sdk
project includes the first version of the SDK, with a light React
implementation. The first version lays down the foundations of
the iframe sdk framework. The npm package logic is also already
ready to be published.
2025-02-18 16:16:07 +01:00
Nathan Vasse 452dbe8bba (front) add sdk related routes and logic
The create room button is a dedicated route. There is also a bit of
logic implied in this commit, including the BroadcastChannel.
The router has been updated with a /sdk negation in order to avoid
including support and react query debug tool in the iframe.
2025-02-18 16:16:07 +01:00
Nathan Vasse 8818d12ee9 ♻️(front) add option to disable silent login
When displayed in an iframe, not being logged-in causes a redirection
to the home page. Which is not what we want with the SDK integration.
We just want to stay on the same page.
2025-02-18 16:16:07 +01:00
Nathan Vasse fb0c9b766d (front) add quaternary variant to Button
This variant is needed to implement the sdk create room button.
2025-02-18 16:16:07 +01:00
Nathan Vasse ba873196c7 (front) add loader and icon props to Button
These shortcuts improve DX and will be used in the next commits.
2025-02-18 16:16:07 +01:00
Jacques ROUSSEL 48937bb7a3 ♻️(helm) fix helm chart for keycloak stack
Ingress stop working, so this commit fix it
2025-02-14 11:51:31 +01:00
lebaudantoine 6f09eb3602 🚸(frontend) add warning for full screen capture
Following user feedback about infinite loops risks, add warning overlay
when users attempt to share their entire screen. The warning explains
the risk and suggests sharing a specific tab instead, providing options
to stop sharing or dismiss the warning while respecting user preferences
for future sessions.
2025-02-13 16:30:22 +01:00
lebaudantoine b7fb6b1b69 🔖(patch) bump release to 0.1.13 2025-02-12 12:35:55 +01:00
Jacques ROUSSEL 2cd4a6efa8 (helm) add pdbs to deployments
In order to avoid a service interruption during a Kubernetes (k8s)
upgrade, we add a Pod Disruption Budget (PDB) to deployments.
2025-02-12 11:54:08 +01:00
lebaudantoine b5037db685 🔒️(docker) patch libssl3 and libcrypto3 to address CVE-2024-12797
Added temporary root privileges to update OpenSSL libraries. Upgrades libssl3
and libcrypto3 to 3.3.3-r0 to fix HIGH severity vulnerability. Properly
switches back to nginx user after updates. Maintains unprivileged execution
while addressing security concern affecting RFC7250 Raw Public Keys
authentication.

Security: CVE-2024-12797
2025-02-12 11:52:40 +01:00
lebaudantoine 19804d2e3f (frontend) add sound notification for unread messages
Add togglable audio alerts when new chat messages arrive,
allowing users to customize their notification preferences.
2025-02-12 11:52:40 +01:00
lebaudantoine a979f05549 🐛(frontend) preserve notification preferences while merging new types
When loading notification settings from localStorage,
keep user preferences for existing notification types while adding
new notification types with default values.

If a notification type is removed, make sure to get rid of it.
My initial implementation wasn't future proof.
2025-02-12 11:52:40 +01:00
lebaudantoine 09a0496d25 ♻️(frontend) standardize toast notification durations
Introduce ToastDuration enum to replace magic numbers for notification
timeouts. Added semantic duration constants for different notification types
(alerts, messages, join/leave events). Improves maintainability and
consistency across toast implementations.
2025-02-12 11:52:40 +01:00
lebaudantoine da95e804a0 (frontend) add toast notifications for unread chat messages
Users frequently miss chat messages due to discrete visual indicators.
Implemented toast notifications showing sender name and message preview to
improve visibility. Added message tracking and auto-dismiss when chat panel
opens.

Remove the warning in handleDataReceived function, it was triggered by
chat message events.
2025-02-12 11:52:40 +01:00
lebaudantoine 591a3a5d8b (frontend) notify user when her was muted
Add notification system to handle user mute events. Implement extensible
design to support future notification types. Set notification duration to 3s.
2025-02-12 11:52:40 +01:00
lebaudantoine 30ed0da416 (frontend) introduce a new notification type
This notification alert the user when her was muted by another one.
Simple copywritting.
2025-02-12 11:52:40 +01:00
lebaudantoine bd4fcc2a5e (frontend) notify participant when her was muted
Make sure a participant is notified when another one muted her.
It will help display a notification to alert the participant.
2025-02-12 11:52:40 +01:00
lebaudantoine fc36ae8b49 🐛(frontend) fix useRoomData when a participant's name has changed
The participant's name in the query key prevented proper cache invalidation
when renamed. Since name changes don't affect query data, removed this
dependency.
2025-02-12 11:52:40 +01:00
lebaudantoine 87baca247b 🚨(frontend) fix warning component from uncontrolled to controlled
When the hand has never been raised yet, the value is undefined and
not false, which leads to a react aria warning.

It's a bad practice. Fix it.
2025-02-12 11:52:40 +01:00
lebaudantoine 42d3846660 ✏️(frontend) fix minor typo
Typo identified by Celine. No "e" in french.
2025-02-11 12:04:17 +01:00
lebaudantoine 2d393f9f70 (frontend) add controls for full screen screenshare
Requested by several users.
Inspired by Jitsi.

Yet imperfect implementation. Controls on video element of
a screenshare should be disabled.
2025-02-11 12:01:23 +01:00
lebaudantoine b4b4ff79d9 ♻️(frontend) refactor full screen to allow video args
useFullScreen hook is now generic, it allows passing a specific
video element to zoom in.

Needed to zoom a specific video track.
2025-02-11 12:01:23 +01:00
lebaudantoine 861244ce01 💄(frontend) update cursor on disabled button
When a button is disabled, it's misleading to show a pointer.
Update it to the default pointer.
2025-02-11 12:01:23 +01:00
lebaudantoine 6373593de3 🚸(frontend) offer additionnal controls on participant tile
On hover, based on participant's type (remove/local) offer an
appropriate action. Either applying effects on the local participant
video or muting the remote participant.

This is a huge enhancement in term of UX, nobody was finding these two
controls in the current menus, and though the features were not
implemented.
2025-02-11 12:01:23 +01:00
lebaudantoine 03f6e6519b 🩹(frontend) remove warning on modal without heading
The MuteAlertDialog was triggering accessibility warnings, lacking
a proper heading. Fix it.
2025-02-11 12:01:23 +01:00
lebaudantoine 716d40dd4e 🎨(frontend) avoid duplicating prefix in i18n's key
Simple enhancement. Avoid duplicating prefix.
2025-02-11 12:01:23 +01:00
lebaudantoine 17f8ec6319 🚸(frontend) refactor participant tile while being hovered
Inspired by GMeet. Make central actions available on a participant
tile when a user hover it.

This new interactive zone will be extended with more actions and
controls.
2025-02-11 12:01:23 +01:00
lebaudantoine 0b74cf96f2 ♻️(frontend) extract MuteAlertDialog in a dedicated component
Will be reused in actions displayed when a participant tile
is focused.
2025-02-11 12:01:23 +01:00
Jacques ROUSSEL 723b8718f9 🔐(helm) bump chart version
Bump chart version to publish a new one with evolution
2025-02-05 22:20:49 +01:00
Nathan Vasse 07a44dab36 (front) turn on camera when using effects
We want to automatically turn on the camera if its off when
enabling a new effect.
2025-02-05 11:57:00 +01:00
Nathan Vasse e47b027bbc (front) add loading state on effects component
useSyncAfterDelay allows to enable loading indicators only if the
loading takes more than a specific time. It prevent blinking
effect when the loading time is nearly instant.
2025-02-05 11:57:00 +01:00
Nathan Vasse d7a4f3946c (front) add Loader component
This component will be used for various purpose.
2025-02-05 11:57:00 +01:00
Jacques ROUSSEL 1b7523bbf1 💚(github) fix argocd notification
Use the right variable for webhook url
2025-02-05 11:53:56 +01:00
Jacques ROUSSEL 4326df4b6a 💚(github) fix argocd notification
Fix double simple quote issue on argocd notification job
2025-02-05 11:48:38 +01:00
Jacques ROUSSEL 564d31ab49 💚(github) remove secret fetch
The secrets are not managed in the folder anymore.
2025-02-05 11:41:37 +01:00
lebaudantoine 6e0948c696 🩹(frontend) prevent support toggle when Crisp chat is blocked
Some users have ad-blockers or privacy extensions that prevent the Crisp chat
widget script from loading properly. This was causing the support toggle to
still display in rooms, but clicking it had no effect since Crisp was blocked.

This ensures users don't see an inactive support button when their browser
is blocking Crisp functionality.
2025-01-31 17:01:57 +01:00
Nathan Vasse eff0cb4afb (front) implement new ui of EffectsConfiguration
This new ui implement the new sketches and also enables the usage of
virtual background in a nice way.
2025-01-31 16:52:44 +01:00
Nathan Vasse 08c5245b48 (front) handle virtual background in custom processor
BackgroundBlurCustomProcessor is renamed to BackgroundCustomProcessor in
order to reflect the fact that is now handles virtual backgrounds too.
BackgroundBlurFactory is also renamed to BackgroundProcessorFactory.
The processor serialization handling has also been updated in order
to support various options, also if persisted in local storage.
2025-01-31 16:52:44 +01:00
Nathan Vasse 465bf293f0 (front) add BackgroundVirtualTrackProcessorJsWrapper
This is a wrapper around track-processor-js virtual background
processor. This is needed in order to be used in a generic way
in our code between firefox processors.
2025-01-31 16:52:44 +01:00
lebaudantoine db51ca1aa5 🩹(frontend) fix border radius issue on safari
I introduced a bug while moving the border radius css style to the
parent element of the video.

On safari, the video element wasn't rounded anymore.

Fix this! Please note our approach should be refactored, nit-picking,
but there are few pixels leaking from the black background on the
video corner.
2025-01-31 14:54:26 +01:00
lebaudantoine 6935001aab 🚸(frontend) disable few native html video option
Disable Picture-In-Picture option for browsers that support it,
to avoid having the option appearing on the video element.

It's not appropriate.
Actually, I am not sure we should disable remote playback ones,
feel free to challenge it.
2025-01-31 14:54:26 +01:00
lebaudantoine bbc2b1d9f6 🚸(frontend) avoid displaying the effects option when not optimal
On unsupported browser showing this option whitout offering the
blur effects to the user would be quite frustrating.

At the moment, safari user cannot blur their background.

Also, avoid offering the option on mobile which are really
cpu-constrained devices.
2025-01-31 14:54:26 +01:00
lebaudantoine 12e2149b9e ♻️(frontend) extract effects-related logic in a component
Wraps all logics related to the effects on prejoin screen in a
dedicated component.
2025-01-31 14:54:26 +01:00
lebaudantoine 3bd9363879 🔍️(frontend) add link to the source code in the footer
Great idea suggested by @fflorent — totally worth it!
2025-01-31 14:30:52 +01:00
lebaudantoine b48135c3b6 🚸(frontend) replace effects icons in menu option
Align effects icon with the one from the newly refactored pre-join screen.
This new icon is stylish.
2025-01-31 14:21:48 +01:00
156 changed files with 38518 additions and 393 deletions
+4 -21
View File
@@ -11,34 +11,17 @@ jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
+7 -77
View File
@@ -19,26 +19,9 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -48,7 +31,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -69,26 +52,9 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -98,7 +64,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -120,26 +86,9 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -149,7 +98,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -162,39 +111,20 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
- build-and-push-summary
runs-on: ubuntu-latest
if: |
github.event_name != 'pull_request'
if: github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
+37 -6
View File
@@ -175,25 +175,56 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
lint-sdk:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Check linting
run: npm run lint
- name: Check format
run: npm run check
build-sdk:
runs-on: ubuntu-latest
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build SDK
run: npm run build
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
-
name: Checkout repository
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
+1 -3
View File
@@ -3,10 +3,8 @@ run-name: Release Chart
on:
push:
branches:
- 'main'
paths:
- ./src/helm/meet/**
- src/helm/meet/**
jobs:
release:
+1
View File
@@ -1,3 +1,4 @@
# Changelog
All notable changes to this project will be documented in this file.
+7
View File
@@ -1,6 +1,10 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
def clean_old_images(image_name):
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
docker_build(
'localhost:5001/meet-backend:latest',
context='..',
@@ -15,6 +19,7 @@ docker_build(
)
]
)
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend:latest',
@@ -26,6 +31,7 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend')
docker_build(
'localhost:5001/meet-summary:latest',
@@ -37,6 +43,7 @@ docker_build(
sync('../src/summary', '/home/summary'),
]
)
clean_old_images('localhost:5001/meet-summary')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.12"
version = "0.1.14"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+5
View File
@@ -34,6 +34,11 @@ RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.12",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.12",
"version": "0.1.14",
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.12",
"version": "0.1.14",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+26
View File
@@ -121,6 +121,32 @@ const config: Config = {
'50%': { opacity: '0.65' },
'100%': { opacity: '1' },
},
rotate: {
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
},
prixClipFix: {
'0%': {
clipPath: 'polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0)',
},
'25%': {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0)',
},
'50%': {
clipPath:
'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%)',
},
'75%': {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%)',
},
'100%': {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
},
},
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+26 -1
View File
@@ -5,7 +5,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { Switch, Route, useLocation } from 'wouter'
import { I18nProvider } from 'react-aria-components'
import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
@@ -13,10 +13,35 @@ import { routes } from './routes'
import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
const SDK_BASE_ROUTE = '/sdk'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const [location] = useLocation()
const isSDKRoute = location.startsWith(SDK_BASE_ROUTE)
if (isSDKRoute) {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Switch>
<Route path={SDK_BASE_ROUTE} nest>
<Route path="/create-button">
<SdkCreateButton />
</Route>
</Route>
</Switch>
</I18nProvider>
</Suspense>
</QueryClientProvider>
)
}
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
+24
View File
@@ -0,0 +1,24 @@
export const VisioIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.841702 8.95427C0.75 9.42479 0.75 10.0042 0.75 10.9748V13.3096C0.75 14.5138 0.75 15.1159 0.925134 15.6549C1.08009 16.1319 1.33356 16.5709 1.6691 16.9435C2.04833 17.3647 2.56977 17.6658 3.61264 18.2679L5.6346 19.4353C6.55753 19.9681 7.07208 20.2652 7.56247 20.4081V13.2043C7.56247 12.8442 7.36578 12.5129 7.04965 12.3404L0.841702 8.95427Z"
fill="currentColor"
/>
<path
d="M17.747 10.0123V13.7685C17.747 14.2878 17.9727 14.7815 18.3654 15.1214L21.0688 17.4609C21.227 17.5947 21.3912 17.7042 21.5616 17.7894C21.738 17.8685 21.9084 17.9081 22.0726 17.9081C22.4255 17.9081 22.7084 17.7925 22.9213 17.5613C23.1404 17.324 23.2499 17.0168 23.2499 16.6396V7.14866C23.2499 6.77146 23.1404 6.46726 22.9213 6.23607C22.7084 5.9988 22.4255 5.88017 22.0726 5.88017C21.9084 5.88017 21.738 5.91971 21.5616 5.9988C21.3912 6.07789 21.227 6.1874 21.0688 6.32733L18.3675 8.65759C17.9735 8.99746 17.747 9.49201 17.747 10.0123Z"
fill="currentColor"
/>
<path
d="M1.74517 7.61282C1.67 7.57182 1.59117 7.54405 1.51135 7.52872C1.56171 7.46443 1.61433 7.40178 1.66914 7.34091C2.04837 6.91973 2.56981 6.61868 3.61268 6.01657L5.63464 4.84919C6.67751 4.24708 7.19894 3.94603 7.7533 3.82819C8.2438 3.72394 8.75074 3.72394 9.24124 3.82819C9.7956 3.94603 10.317 4.24708 11.3599 4.84919L13.358 6.0028C13.366 6.00738 13.374 6.01197 13.3819 6.01658C14.4248 6.61868 14.9462 6.91973 15.3255 7.34091C15.661 7.71357 15.9145 8.15259 16.0694 8.62951C16.2446 9.16852 16.2446 9.77063 16.2446 10.9748V13.3096C16.2446 14.5138 16.2446 15.1159 16.0694 15.6549C15.9145 16.1319 15.661 16.5709 15.3255 16.9435C14.9462 17.3647 14.4248 17.6657 13.382 18.2678C13.373 18.273 13.364 18.2782 13.3551 18.2833L11.3599 19.4353C10.317 20.0374 9.7956 20.3384 9.24124 20.4562C9.21846 20.4611 9.19564 20.4657 9.17279 20.4701L9.17278 13.2043C9.17278 12.255 8.65422 11.3814 7.82079 10.9268L1.74517 7.61282Z"
fill="currentColor"
/>
</svg>
)
}
@@ -1,12 +1,19 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Bold } from '@/primitives'
export const NotFoundScreen = () => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={t('notFound.heading')} withBackButton />
<CenteredContent title={t('notFound.heading')} withBackButton>
<Text centered>
{t('notFound.body')}{' '}
<Bold>https://visio.numerique.gouv.fr/xxx-yyyy-zzz.</Bold>
</Text>
</CenteredContent>
</Screen>
)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,7 +10,13 @@ import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
* Here our wrapper just returns false in that case, without triggering an error:
* this is done to prevent unnecessary query retries with react query
*/
export const fetchUser = (): Promise<ApiUser | false> => {
export const fetchUser = (
opts: {
attemptSilent?: boolean
} = {
attemptSilent: true,
}
): Promise<ApiUser | false> => {
return new Promise((resolve, reject) => {
fetchApi<ApiUser>('/users/me')
.then(resolve)
@@ -19,7 +25,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
if (error instanceof ApiError && error.statusCode === 401) {
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (canAttemptSilentLogin()) {
if (opts.attemptSilent && canAttemptSilentLogin()) {
attemptSilentLogin(300)
} else {
resolve(false)
@@ -11,10 +11,15 @@ import { initializeSupportSession } from '@/features/support/hooks/useSupport'
*
* `isLoggedIn` is undefined while query is loading and true/false when it's done
*/
export const useUser = () => {
export const useUser = (
opts: {
fetchUserOptions?: Parameters<typeof fetchUser>[0]
} = {}
) => {
const query = useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: [keys.user],
queryFn: fetchUser,
queryFn: () => fetchUser(opts.fetchUserOptions),
staleTime: Infinity,
})
+14 -1
View File
@@ -13,11 +13,12 @@ import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -156,6 +157,18 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const { user } = useUser()
/**
* Used for SDK popup to close automatically.
*/
useEffect(() => {
if (!user) {
return
}
SdkReverseClient.broadcastAuthentication()
}, [user])
return (
<UserAware>
<Screen>
@@ -1,16 +1,99 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RoomEvent } from 'livekit-client'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { Div } from '@/primitives'
import { isMobileBrowser } from '@livekit/components-core'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { useTranslation } from 'react-i18next'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
useEffect(() => {
const handleChatMessage = (
chatMessage: ChatMessage,
participant?: Participant | undefined
) => {
if (!participant || participant.isLocal) return
triggerNotificationSound(NotificationType.MessageReceived)
toastQueue.add(
{
participant: participant,
message: chatMessage.message,
type: NotificationType.MessageReceived,
},
{ timeout: NotificationDuration.MESSAGE }
)
}
room.on(RoomEvent.ChatMessage, handleChatMessage)
return () => {
room.off(RoomEvent.ChatMessage, handleChatMessage)
}
}, [room, triggerNotificationSound])
const handleEmoji = (emoji: string, participant: Participant) => {
if (!emoji) return
const id = instanceIdRef.current++
setReactions((prev) => [
...prev,
{
id,
emoji,
participant,
},
])
setTimeout(() => {
setReactions((prev) => prev.filter((instance) => instance.id !== id))
}, ANIMATION_DURATION)
}
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const decoder = new TextDecoder()
const notificationPayload = JSON.parse(decoder.decode(payload))
const notificationType = notificationPayload.type
const data = notificationPayload.data
if (!participant) return
switch (notificationType) {
case NotificationType.ParticipantMuted:
toastQueue.add(
{
participant,
type: NotificationType.ParticipantMuted,
},
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.ReactionReceived:
handleEmoji(data?.emoji, participant)
break
default:
return
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [room])
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
if (isMobileBrowser()) {
@@ -23,7 +106,7 @@ export const MainNotificationToast = () => {
type: NotificationType.ParticipantJoined,
},
{
timeout: 5000,
timeout: NotificationDuration.PARTICIPANT_JOINED,
}
)
}
@@ -82,7 +165,7 @@ export const MainNotificationToast = () => {
participant,
type: NotificationType.HandRaised,
},
{ timeout: 5000 }
{ timeout: NotificationDuration.HAND_RAISED }
)
}
}
@@ -104,9 +187,14 @@ export const MainNotificationToast = () => {
}
}, [room])
// Without this line, when the component first renders,
// the 'notifications' namespace might not be loaded yet
useTranslation(['notifications'])
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<ReactionPortals reactions={reactions} />
</Div>
)
}
@@ -0,0 +1,15 @@
export enum ToastDuration {
SHORT = 3000,
MEDIUM = 4000,
LONG = 5000,
EXTRA_LONG = 7000,
}
export const NotificationDuration = {
ALERT: ToastDuration.SHORT,
MESSAGE: ToastDuration.LONG,
PARTICIPANT_JOINED: ToastDuration.LONG,
HAND_RAISED: ToastDuration.LONG,
LOWER_HAND: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
} as const
@@ -0,0 +1,8 @@
import { NotificationType } from './NotificationType'
export interface NotificationPayload {
type: NotificationType
data?: {
emoji?: string
}
}
@@ -1,5 +1,8 @@
export enum NotificationType {
ParticipantJoined = 'participantJoined',
HandRaised = 'handRaised',
// todo - implement message received notification
ParticipantMuted = 'participantMuted',
MessageReceived = 'messageReceived',
LowerHand = 'lowerHand',
ReactionReceived = 'reactionReceived',
}
@@ -0,0 +1,45 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
export function ToastLowerHand({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const toast = props.toast
const handleDismiss = () => {
// Clear onClose handler to prevent lowering the hand when user dismisses
toast.onClose = undefined
state.close(toast.key)
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
<p>{t('auto')}</p>
<Button
size="sm"
variant="text"
style={{
color: '#60a5fa',
marginLeft: '0.5rem',
}}
onPress={() => handleDismiss()}
>
{t('dismiss')}
</Button>
</HStack>
</StyledToastContainer>
)
}
@@ -0,0 +1,74 @@
import { useToast } from '@react-aria/toast'
import { useEffect, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { Text } from '@/primitives'
import { RiMessage2Line } from '@remixicon/react'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Button as RACButton } from 'react-aria-components'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export function ToastMessageReceived({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps } = useToast(props, state, ref)
const toast = props.toast
const participant = toast.content.participant
const message = toast.content.message
const { isChatOpen, toggleChat } = useSidePanel()
useEffect(() => {
if (isChatOpen) {
state.close(toast.key)
}
}, [isChatOpen, toast, state])
if (isChatOpen) return null
return (
<StyledToastContainer {...toastProps} ref={ref}>
<RACButton onPress={() => toggleChat()} aria-label={t('openChat')}>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
padding: '14px',
gap: '0.75rem',
textAlign: 'start',
width: '150px',
md: {
width: '260px',
},
})}
>
<div
className={css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: '0.5rem',
})}
>
<RiMessage2Line
size={20}
className={css({
color: 'primary.300',
marginTop: '3px',
})}
aria-hidden="true"
/>
<span>{participant.name}</span>
</div>
<Text margin={false} centered={false} wrap={'pretty'} fullWidth>
{message}
</Text>
</div>
</RACButton>
</StyledToastContainer>
)
}
@@ -0,0 +1,26 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
export function ToastMuted({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t('muted', { name: participant.name })}
</HStack>
</StyledToastContainer>
)
}
@@ -1,30 +1,52 @@
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import type { ToastState } from '@react-stately/toast'
import type { QueuedToast, 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'
import { ToastRaised } from './ToastRaised'
import { ToastMuted } from './ToastMuted'
import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
}
const renderToast = (
toast: QueuedToast<ToastData>,
state: ToastState<ToastData>
) => {
switch (toast.content?.type) {
case NotificationType.ParticipantJoined:
return <ToastJoined key={toast.key} toast={toast} state={state} />
case NotificationType.HandRaised:
return <ToastRaised key={toast.key} toast={toast} state={state} />
case NotificationType.ParticipantMuted:
return <ToastMuted key={toast.key} toast={toast} state={state} />
case NotificationType.MessageReceived:
return (
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
)
case NotificationType.LowerHand:
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
}
export function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef(null)
const { regionProps } = useToastRegion(props, state, ref)
return (
<div {...regionProps} ref={ref} className="toast-region">
{state.visibleToasts.map((toast) => {
if (toast.content?.type === NotificationType.ParticipantJoined) {
return <ToastJoined key={toast.key} toast={toast} state={state} />
}
if (toast.content?.type === NotificationType.HandRaised) {
return <ToastRaised key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} />
})}
{state.visibleToasts.map((toast) => renderToast(toast, state))}
</div>
)
}
@@ -0,0 +1,28 @@
import { toastQueue } from './components/ToastProvider'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { Participant } from 'livekit-client'
export const showLowerHandToast = (
participant: Participant,
onClose: () => void
) => {
toastQueue.add(
{
participant,
type: NotificationType.LowerHand,
},
{
timeout: NotificationDuration.LOWER_HAND,
onClose,
}
)
}
export const closeLowerHandToasts = () => {
toastQueue.visibleToasts.forEach((toast) => {
if (toast.content.type === NotificationType.LowerHand) {
toastQueue.close(toast.key)
}
})
}
@@ -16,7 +16,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
import { LocalUserChoices } from '../routes/Room'
import { BackgroundBlurFactory } from '../livekit/components/blur'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
export const Conference = ({
roomId,
@@ -32,7 +32,7 @@ export const Conference = ({
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
const fetchKey = [keys.room, roomId, userConfig.username]
const fetchKey = [keys.room, roomId]
const {
mutateAsync: createRoom,
@@ -49,6 +49,7 @@ export const Conference = ({
isError: isFetchError,
data,
} = useQuery({
/* eslint-disable @tanstack/query/exhaustive-deps */
queryKey: fetchKey,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
initialData: initialRoomData,
@@ -114,7 +115,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundBlurFactory.deserializeProcessor(
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
),
}
@@ -7,18 +7,96 @@ import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Form } from '@/primitives'
import { Button, Dialog, Text, Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { Button, Dialog } from '@/primitives'
import { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import { EffectsConfiguration } from '../livekit/components/effects/EffectsConfiguration'
import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../livekit/components/blur'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core'
const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({
videoTrack,
onSubmit,
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true)
if (!BackgroundProcessorFactory.isSupported() || isMobileBrowser()) {
return
}
return (
<>
<Dialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
role="dialog"
type="flex"
size="large"
>
<Heading
slot="title"
level={1}
className={css({
textStyle: 'h1',
marginBottom: '0.25rem',
})}
>
{t('title')}
</Heading>
<Text
variant="subTitle"
className={css({
marginBottom: '1.5rem',
})}
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
zIndex: '1',
})}
>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
borderBottomRadius: '1rem',
})}
/>
</>
)
}
export const Join = ({
onSubmit,
}: {
@@ -34,6 +112,8 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices({})
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
@@ -42,7 +122,7 @@ export const Join = ({
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
const [processor, setProcessor] = useState(
BackgroundBlurFactory.deserializeProcessor(
BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.processorSerialized
)
)
@@ -58,12 +138,15 @@ export const Join = ({
useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [processor, saveProcessorSerialized])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
@@ -123,12 +206,6 @@ export const Join = ({
})
}
const [isEffectsOpen, setEffectsOpen] = useState(false)
const openEffects = () => {
setEffectsOpen(true)
}
// This hook is used to setup the persisted user choice processor on initialization.
// So it's on purpose that processor is not included in the deps.
// We just want to wait for the videoTrack to be loaded to apply the default processor.
@@ -141,30 +218,6 @@ export const Join = ({
return (
<Screen footer={false}>
<Dialog
isOpen={isEffectsOpen}
onOpenChange={setEffectsOpen}
role="dialog"
type="flex"
size="large"
>
<Heading
slot="title"
level={1}
className={css({
textStyle: 'h1',
marginBottom: '1.5rem',
})}
>
{t('effects.title')}
</Heading>
<EffectsConfiguration
videoTrack={videoTrack}
onSubmit={(processor) => {
setProcessor(processor)
}}
/>
</Dialog>
<div
className={css({
display: 'flex',
@@ -227,13 +280,16 @@ export const Join = ({
height="720"
className={css({
display: 'block',
width: '102%',
height: '102%',
width: '100%',
height: '100%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
borderRadius: '1rem',
})}
disablePictureInPicture
disableRemotePlayback
/>
) : (
<div
@@ -257,34 +313,7 @@ export const Join = ({
</p>
</div>
)}
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(255,255,255,0) 100%)',
})}
></div>
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
})}
>
<Button
variant="whiteCircle"
onPress={openEffects}
tooltip={t('effects.description')}
aria-label={t('effects.description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
const Card = styled('div', {
base: {
@@ -170,7 +171,7 @@ const RateQuality = ({
<H lvl={3}>{t('question')}</H>
<Bar>
{[...Array(maxRating)].map((_, index) => (
<Button
<RACButton
key={index}
onPress={() => handleRatingClick(index + 1)}
className={ratingButtonRecipe({
@@ -179,7 +180,7 @@ const RateQuality = ({
})}
>
{index + 1}
</Button>
</RACButton>
))}
</Bar>
<div
@@ -3,11 +3,27 @@ import Source = Track.Source
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
export const useMuteParticipant = () => {
const data = useRoomData()
const room = useRoomContext()
const muteParticipant = (participant: Participant) => {
const notifyParticipant = async (participant: Participant) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ParticipantMuted,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
destinationIdentities: [participant.identity],
})
}
const muteParticipant = async (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
@@ -18,22 +34,33 @@ export const useMuteParticipant = () => {
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,
}),
}
)
try {
const response = await fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/MutePublishedTrack'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
muted: true,
track_sid: trackSid,
}),
}
)
await notifyParticipant(participant)
return response
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { muteParticipant }
}
@@ -0,0 +1,148 @@
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useMemo, useRef } from 'react'
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
import { useSize } from '../hooks/useResizeObserver'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
export const FullScreenShareWarning = ({
trackReference,
}: {
trackReference: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
const warningContainerRef = useRef<HTMLDivElement>(null)
const { width: containerWidth } = useSize(warningContainerRef)
const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
const isFullScreenSharing = useMemo(() => {
if (trackReference?.source !== 'screen_share') return false
const mediaStreamTrack = trackReference.publication?.track?.mediaStreamTrack
if (!mediaStreamTrack) return false
// Method 1: Display Surface Check (Chrome & Edge)
const trackSettings = mediaStreamTrack.getSettings()
if (trackSettings?.displaySurface) {
return trackSettings.displaySurface === 'monitor'
}
// Method 2: Track Constraints Check
const constraints = mediaStreamTrack.getConstraints()
if (constraints?.displaySurface) {
return constraints.displaySurface === 'monitor'
}
// Method 3: Label Analysis (Firefox, Safari)
const trackLabel = mediaStreamTrack.label.toLowerCase()
const fullScreenIndicators = [
'monitor',
'screen',
'display',
'entire screen',
'full screen',
]
return fullScreenIndicators.some((indicator) =>
trackLabel.includes(indicator)
)
}, [trackReference])
const shouldShowWarning =
screenSharePreferences.enabled && isFullScreenSharing
const handleStopScreenShare = async () => {
if (!localParticipant.isScreenShareEnabled) return
await localParticipant.setScreenShareEnabled(false, {}, {})
}
const handleDismissWarning = () => {
ScreenSharePreferenceStore.enabled = false
}
if (!shouldShowWarning) return null
return (
<div
className={css({
position: 'absolute',
zIndex: '1000',
height: '100%',
width: '100%',
})}
ref={warningContainerRef}
>
{(!containerWidth || containerWidth >= 300) && (
<div
className={css({
position: 'absolute',
zIndex: '1000',
height: '100%',
width: '100%',
backgroundColor: 'rgba(22, 22, 34, 0.9)',
padding: '2rem',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
flexDirection: 'column',
gap: '1rem',
md: {
flexDirection: 'row',
},
})}
>
<Text
style={{
color: 'white',
flexBasis: '55%',
fontWeight: '500',
}}
margin={false}
wrap={'pretty'}
>
{t('message')}
</Text>
<div
className={css({
display: 'flex',
flexDirection: 'row',
gap: '1rem',
})}
>
<Button
variant="tertiary"
size="sm"
style={{
height: 'fit-content',
}}
onPress={async () => {
await handleStopScreenShare()
}}
>
{t('stop')}
</Button>
<Button
variant="primaryTextDark"
size="sm"
style={{
height: 'fit-content',
}}
onPress={() => handleDismissWarning()}
>
{t('ignore')}
</Button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
export const MuteAlertDialog = ({
isOpen,
onClose,
onSubmit,
name,
}: {
isOpen: boolean
onClose: () => void
onSubmit: () => void
name: string
}) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participants.muteParticipantAlert',
})
return (
<Dialog
isOpen={isOpen}
role="alertdialog"
aria-label={t('heading', { name })}
>
<P>{t('description', { name })}</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
{t('cancel')}
</Button>
<Button variant="text" size="sm" onPress={onSubmit}>
{t('confirm')}
</Button>
</HStack>
</Dialog>
)
}
@@ -1,7 +1,6 @@
import {
AudioTrack,
ConnectionQualityIndicator,
FocusToggle,
LockLockedIcon,
ParticipantName,
ParticipantTileProps,
@@ -23,11 +22,13 @@ import {
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
import { RiHand } from '@remixicon/react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useRaisedHand } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -100,6 +101,7 @@ export const ParticipantTile: (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
{children ?? (
<>
{isTrackReference(trackReference) &&
@@ -173,7 +175,9 @@ export const ParticipantTile: (
)}
</>
)}
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
{!disableMetadata && (
<ParticipantTileFocus trackRef={trackReference} />
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
</div>
@@ -0,0 +1,224 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import {
RiFullscreenLine,
RiImageCircleAiFill,
RiMicLine,
RiMicOffLine,
RiPushpin2Line,
RiUnpinLine,
} from '@remixicon/react'
import {
useFocusToggle,
useTrackMutedIndicator,
} from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useEffect, useRef, useState } from 'react'
import { useSidePanel } from '../hooks/useSidePanel'
import { useFullScreen } from '../hooks/useFullScreen'
import { Participant, Track } from 'livekit-client'
import { MuteAlertDialog } from './MuteAlertDialog'
import { useMuteParticipant } from '../api/muteParticipant'
const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { mergedProps, inFocus } = useFocusToggle({
trackRef,
props: {},
})
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPress={(event) => mergedProps?.onClick?.(event as any)}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
const EffectsButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size={'sm'}
variant={'primaryTextDark'}
square
tooltip={t('effects')}
onPress={() => !isEffectsOpen && toggleEffects()}
>
<RiImageCircleAiFill />
</Button>
)
}
const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
useEffect(() => {
if (hovered && !isIdleRef) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
})
} else {
setOpacity(0)
}
}, [hovered, isIdleRef])
const handleMouseMove = () => {
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
}
idleTimerRef.current = window.setTimeout(() => {
setIsIdleRef(true)
}, MOUSE_IDLE_TIME)
setIsIdleRef(false)
}
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = trackRef.participant.isLocal
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{hovered && (
<div
className={css({
backgroundColor: 'primaryDark.50',
transition: 'opacity 200ms linear',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
_hover: {
opacity: '0.95 !important',
},
})}
style={{ opacity }}
>
<HStack
gap={0.5}
className={css({
padding: '0.5rem',
_hover: {
opacity: '1 !important',
},
})}
>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{participant.isLocal ? (
<EffectsButton />
) : (
<MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
import { createPortal } from 'react-dom'
import { useState, useEffect, useMemo } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
export const FADE_OUT_THRESHOLD = 0.7
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
export const INITIAL_POSITION = 200
interface FloatingReactionProps {
emoji: string
name?: string
speed?: number
scale?: number
}
export function FloatingReaction({
emoji,
name,
speed = 1,
scale = 1,
}: FloatingReactionProps) {
const [deltaY, setDeltaY] = useState(0)
const [opacity, setOpacity] = useState(1)
const left = useMemo(
() => Math.random() * window.innerWidth * REACTION_SPAWN_WIDTH_RATIO,
[]
)
useEffect(() => {
let start: number | null = null
function animate(timestamp: number) {
if (start === null) start = timestamp
const elapsed = timestamp - start
if (elapsed < 0) {
setOpacity(0)
} else {
const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
const distance = ANIMATION_DISTANCE * speed
const newY = progress * distance
setDeltaY(newY)
if (progress > FADE_OUT_THRESHOLD) {
setOpacity(1 - (progress - FADE_OUT_THRESHOLD) / 0.3)
}
}
if (elapsed < ANIMATION_DURATION) {
requestAnimationFrame(animate)
}
}
const req = requestAnimationFrame(animate)
return () => cancelAnimationFrame(req)
}, [speed])
return (
<div
className={css({
fontSize: '3rem',
position: 'absolute',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
})}
style={{
left: left,
bottom: INITIAL_POSITION + deltaY,
transform: `scale(${scale})`,
opacity: opacity,
}}
>
<span
className={css({
lineHeight: '45px',
})}
>
{emoji}
</span>
{name && (
<Text
variant="sm"
className={css({
backgroundColor: 'primaryDark.100',
color: 'white',
textAlign: 'center',
borderRadius: '20px',
paddingX: '0.5rem',
paddingY: '0.15rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
lineHeight: '14px',
})}
>
{name}
</Text>
)}
</div>
)
}
export function ReactionPortal({
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal(
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
>
<FloatingReaction
emoji={emoji}
speed={speed}
scale={scale}
name={participant?.isLocal ? t('you') : participant.name}
/>
</div>,
document.body
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
@@ -5,7 +5,7 @@ import {
} from '@livekit/track-processors'
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlurProcessorInterface,
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
@@ -16,9 +16,9 @@ import {
* used accross the project.
*/
export class BackgroundBlurTrackProcessorJsWrapper
implements BackgroundBlurProcessorInterface
implements BackgroundProcessorInterface
{
name: string = 'Blur'
name: string = 'blur'
processor: ProcessorWrapper<BackgroundOptions>
@@ -12,7 +12,7 @@ import {
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundBlurProcessorInterface,
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
@@ -32,9 +32,7 @@ const DEFAULT_BLUR = '10'
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
* MediaStreamTrackProcessor.
*/
export class BackgroundBlurCustomProcessor
implements BackgroundBlurProcessorInterface
{
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -63,9 +61,18 @@ export class BackgroundBlurCustomProcessor
timerWorker?: Worker
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
this.name = 'blur'
this.options = opts
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
}
static get isSupported() {
@@ -81,6 +88,7 @@ export class BackgroundBlurCustomProcessor
this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement
this._initVirtualBackgroundImage()
this._createMainCanvas()
this._createMaskCanvas()
@@ -98,8 +106,21 @@ export class BackgroundBlurCustomProcessor
posthog.capture('firefox-blurring-init')
}
_initVirtualBackgroundImage() {
const needsUpdate =
this.options.imagePath &&
this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath
if (this.options.imagePath || needsUpdate) {
this.virtualBackgroundImage = document.createElement('img')
this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath!
}
}
update(opts: BackgroundOptions): void {
this.options = opts
this._initVirtualBackgroundImage()
}
_initWorker() {
@@ -201,6 +222,7 @@ export class BackgroundBlurCustomProcessor
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
@@ -213,19 +235,69 @@ export class BackgroundBlurCustomProcessor
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw blurry background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
/**
* TODO: future improvement with WebGL.
*/
async drawVirtualBackground() {
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
}
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw virtual background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.drawImage(
this.virtualBackgroundImage!,
0,
0,
this.outputCanvas!.width,
this.outputCanvas!.height
)
}
async process() {
await this.sizeSource()
await this.segment()
await this.blur()
if (this.options.blurRadius) {
await this.blur()
} else {
await this.drawVirtualBackground()
}
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
@@ -284,12 +356,12 @@ export class BackgroundBlurCustomProcessor
}
clone() {
return new BackgroundBlurCustomProcessor(this.options)
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
type: ProcessorType.BLUR,
type: this.type,
options: this.options,
}
}
@@ -0,0 +1,61 @@
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
import {
BackgroundTransformer,
ProcessorWrapper,
VirtualBackground,
} from '@livekit/track-processors'
export class BackgroundVirtualTrackProcessorJsWrapper
implements BackgroundProcessorInterface
{
name = 'virtual'
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
constructor(opts: BackgroundOptions) {
this.processor = VirtualBackground(opts.imagePath!)
this.opts = opts
}
async init(opts: ProcessorOptions<Track.Kind>) {
return this.processor.init(opts)
}
async restart(opts: ProcessorOptions<Track.Kind>) {
return this.processor.restart(opts)
}
async destroy() {
return this.processor.destroy()
}
update(opts: BackgroundOptions): void {
this.processor.updateTransformerOptions(opts)
}
get processedTrack() {
return this.processor.processedTrack
}
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new BackgroundVirtualTrackProcessorJsWrapper(this.options)
}
serialize() {
return {
type: ProcessorType.VIRTUAL,
options: this.options,
}
}
}
@@ -1,10 +1,12 @@
import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
import { BackgroundBlurCustomProcessor } from './BackgroundBlurCustomProcessor'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
}
export interface ProcessorSerialized {
@@ -12,40 +14,49 @@ export interface ProcessorSerialized {
options: BackgroundOptions
}
export interface BackgroundBlurProcessorInterface
export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): void
options: BackgroundOptions
clone(): BackgroundBlurProcessorInterface
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
export enum ProcessorType {
BLUR = 'blur',
VIRTUAL = 'virtual',
}
export class BackgroundBlurFactory {
export class BackgroundProcessorFactory {
static isSupported() {
return (
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(
type: ProcessorType,
opts: BackgroundOptions
): BackgroundBlurProcessorInterface | undefined {
if (ProcessorWrapper.isSupported) {
return new BackgroundBlurTrackProcessorJsWrapper(opts)
}
if (BackgroundBlurCustomProcessor.isSupported) {
return new BackgroundBlurCustomProcessor(opts)
): BackgroundProcessorInterface | undefined {
if (type === ProcessorType.BLUR) {
if (ProcessorWrapper.isSupported) {
return new BackgroundBlurTrackProcessorJsWrapper(opts)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
} else if (type === ProcessorType.VIRTUAL) {
if (ProcessorWrapper.isSupported) {
return new BackgroundVirtualTrackProcessorJsWrapper(opts)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
}
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type === ProcessorType.BLUR) {
return BackgroundBlurFactory.getProcessor(data?.options)
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
}
return undefined
}
@@ -4,6 +4,13 @@ import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
const SPEAKING_DETECTION_DELAY = 3000
export const HandToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
@@ -13,6 +20,39 @@ export const HandToggle = () => {
participant: room.localParticipant,
})
const isSpeaking = room.localParticipant.isSpeaking
const speakingTimerRef = useRef<NodeJS.Timeout | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
const resetToastState = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
setHasShownToast(true)
const onClose = () => {
if (isHandRaised) toggleRaisedHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
}, SPEAKING_DETECTION_DELAY)
}
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
@@ -28,7 +68,10 @@ export const HandToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={() => toggleRaisedHand()}
onPress={() => {
toggleRaisedHand()
resetToastState()
}}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
@@ -1,4 +1,4 @@
import { RiAccountBoxLine } from '@remixicon/react'
import { RiImageCircleAiFill } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
@@ -13,7 +13,7 @@ export const EffectsMenuItem = () => {
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
<RiImageCircleAiFill size={20} />
{t('effects')}
</MenuItem>
)
@@ -7,7 +7,7 @@ import { useFullScreen } from '../../../hooks/useFullScreen'
export const FullScreenMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleFullScreen, isCurrentlyFullscreen, isFullscreenAvailable } =
useFullScreen()
useFullScreen({})
if (!isFullscreenAvailable) {
return null
@@ -13,36 +13,10 @@ import {
} from '@livekit/components-react'
import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button, Dialog, P } from '@/primitives'
import { Button } 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>
)
}
import { MuteAlertDialog } from '../../MuteAlertDialog'
type MicIndicatorProps = {
participant: Participant
@@ -0,0 +1,94 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { Menu, ToggleButton, Button } from '@/primitives'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
export interface Reaction {
id: number
emoji: string
participant: Participant
}
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const room = useRoomContext()
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ReactionReceived,
data: {
emoji: emoji,
},
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, { reliable: true })
const newReaction = {
id: instanceIdRef.current++,
emoji,
participant: room.localParticipant,
}
setReactions((prev) => [...prev, newReaction])
// Remove this reaction after animation
setTimeout(() => {
setReactions((prev) =>
prev.filter((instance) => instance.id !== newReaction.id)
)
}, ANIMATION_DURATION)
}
return (
<>
<Menu variant="dark" placement="top">
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
>
<RiEmotionLine />
</ToggleButton>
<RACToolbar
className={css({
display: 'flex',
})}
>
{EMOJIS.map((emoji, index) => (
<Button
key={index}
onPress={() => sendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="quaternaryText"
size="sm"
>
<span
className={css({
fontSize: '20px',
})}
>
{emoji}
</span>
</Button>
))}
</RACToolbar>
</Menu>
<ReactionPortals reactions={reactions} />
</>
)
}
@@ -27,7 +27,7 @@ import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../blur'
import { BackgroundProcessorFactory } from '../blur'
export type ToggleSource = Exclude<
Track.Source,
@@ -115,7 +115,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundBlurFactory.deserializeProcessor(
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
@@ -1,13 +1,21 @@
import { LocalVideoTrack } from 'livekit-client'
import { LocalVideoTrack, Track } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BackgroundBlurFactory,
BackgroundBlurProcessorInterface,
BackgroundProcessorFactory,
BackgroundProcessorInterface,
ProcessorType,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H } from '@/primitives'
import { HStack, styled } from '@/styled-system/jsx'
import { styled } from '@/styled-system/jsx'
import { BackgroundOptions } from '@livekit/track-processors'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
enum BlurRadius {
NONE = 0,
@@ -15,30 +23,33 @@ enum BlurRadius {
NORMAL = 10,
}
const isSupported = BackgroundBlurFactory.isSupported()
const isSupported = BackgroundProcessorFactory.isSupported()
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
marginTop: '0.8rem',
alignItems: 'start',
},
})
export type EffectsConfigurationProps = {
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}
export const EffectsConfiguration = ({
videoTrack,
onSubmit,
layout = 'horizontal',
}: {
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundBlurProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}) => {
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
useEffect(() => {
const videoElement = videoRef.current
@@ -53,46 +64,82 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
const toggleBlur = async (blurRadius: number) => {
if (!videoTrack) return
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
* setProcessor on.
*
* We arrive in this condition when we enter the room with the camera already off.
*/
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(
type,
options
)!
await toggle(true, {
processor: newProcessorTmp,
})
setTimeout(() => setProcessorPending(false))
return
}
if (!enabled) {
await toggle(true)
}
const processor = getProcessor()
const currentBlurRadius = getBlurRadius()
try {
if (blurRadius == currentBlurRadius && processor) {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
} else if (!processor) {
const newProcessor = BackgroundBlurFactory.getProcessor({ blurRadius })!
if (isSelected(type, options)) {
// Stop processor.
await clearEffect()
} else if (!processor || processor.serialize().type !== type) {
// Change processor.
const newProcessor = BackgroundProcessorFactory.getProcessor(
type,
options
)!
await videoTrack.setProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
processor?.update({ blurRadius })
// Update processor.
processor?.update(options)
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
} catch (error) {
console.error('Error applying blur:', error)
console.error('Error applying effect:', error)
} finally {
setProcessorPending(false)
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundBlurProcessorInterface
return videoTrack?.getProcessor() as BackgroundProcessorInterface
}
const getBlurRadius = (): BlurRadius => {
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
const processor = getProcessor()
return processor?.options.blurRadius || BlurRadius.NONE
const processorSerialized = processor?.serialize()
return (
!!processor &&
processorSerialized.type === type &&
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
)
}
const isSelected = (blurRadius: BlurRadius) => {
return getBlurRadius() == blurRadius
}
const tooltipLabel = (blurRadius: BlurRadius) => {
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
return (
@@ -102,7 +149,7 @@ export const EffectsConfiguration = ({
? {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
gap: '1.5rem',
}
: {
display: 'flex',
@@ -110,6 +157,7 @@ export const EffectsConfiguration = ({
flexDirection: 'column',
md: {
flexDirection: 'row',
overflow: 'hidden',
},
}
)}
@@ -118,6 +166,7 @@ export const EffectsConfiguration = ({
className={css({
width: '100%',
aspectRatio: 16 / 9,
position: 'relative',
})}
>
{videoTrack && !videoTrack.isMuted ? (
@@ -154,6 +203,17 @@ export const EffectsConfiguration = ({
</P>
</div>
)}
{processorPendingReveal && (
<div
className={css({
position: 'absolute',
right: '8px',
bottom: '8px',
})}
>
<Loader />
</div>
)}
</div>
<div
className={css(
@@ -162,49 +222,148 @@ export const EffectsConfiguration = ({
md: {
borderLeft: '1px solid #dadce0',
paddingLeft: '1.5rem',
width: '420px',
flexShrink: 0,
},
}
: {}
)}
>
<H
lvl={3}
style={{
marginBottom: '0.4rem',
fontWeight: 'bold',
}}
>
{t('heading')}
</H>
{isSupported ? (
<HStack>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.LIGHT)}
tooltip={tooltipLabel(BlurRadius.LIGHT)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
isSelected={isSelected(BlurRadius.LIGHT)}
>
{t('blur.light')}
</ToggleButton>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.NORMAL)}
tooltip={tooltipLabel(BlurRadius.NORMAL)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
isSelected={isSelected(BlurRadius.NORMAL)}
>
{t('blur.normal')}
</ToggleButton>
</HStack>
<>
<div>
<div>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('blur.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal}
>
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
>
<BlurOnStrong />
</ToggleButton>
</div>
</div>
<div
className={css({
marginTop: '1.5rem',
})}
>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('virtual.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
return (
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
></ToggleButton>
)
})}
</div>
</div>
</div>
<Information className={css({ marginTop: '1rem' })}>
<Text variant="sm"> {t('experimental')}</Text>
</Information>
</>
) : (
<Text variant="sm">{t('notAvailable')}</Text>
<Information>
<Text variant="sm">{t('notAvailable')}</Text>
</Information>
)}
<Information>
<Text variant="sm"> {t('experimental')}</Text>
</Information>
</div>
</div>
)
@@ -2,9 +2,32 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
export function useFullScreen({
trackRef,
}: {
trackRef?: TrackReferenceOrPlaceholder
}) {
const videoElement = useMemo(() => {
const elements = trackRef?.publication?.track?.attachedElements
if (!elements) return
// Find the visible video element
const likeKitElement = elements.find((el) =>
el.classList.contains('lk-participant-media-video')
)
if (!likeKitElement) {
console.warn('Could not find LiveKit-managed video element')
return elements[0] || null
}
return likeKitElement
}, [trackRef])
export function useFullScreen() {
const getIsFullscreen = () => {
return !!(
document.fullscreenElement ||
@@ -24,7 +47,7 @@ export function useFullScreen() {
const enterFullscreen = async () => {
try {
const docEl = document.documentElement
const docEl = videoElement || document.documentElement
if (docEl.requestFullscreen) {
await docEl.requestFullscreen()
} else if (docEl.webkitRequestFullscreen) {
@@ -19,5 +19,5 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
}
}
return { isHandRaised: parsedMetadata.raised, toggleRaisedHand }
return { isHandRaised: parsedMetadata.raised || false, toggleRaisedHand }
}
@@ -1,12 +1,10 @@
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]
const queryKey = [keys.room, roomId]
return queryClient.getQueryData<ApiRoom>(queryKey)
}
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
@@ -75,6 +76,7 @@ export function DesktopControlBar({
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
@@ -0,0 +1,84 @@
import { authUrl, useUser } from '../auth'
export enum ClientMessageType {
ROOM_CREATED = 'ROOM_CREATED',
}
export class SdkReverseClient {
/**
* IDEA: Use API Key. Must be based on some sort of credentials? No needs for now as there are no security
* plausible at the moment.
*/
static getAllowTargetOrigin() {
return '*'
}
static post(type: ClientMessageType, data: unknown = {}) {
window.parent.postMessage(
{
type,
data,
},
SdkReverseClient.getAllowTargetOrigin()
)
}
static broadcastAuthentication() {
const bc = new BroadcastChannel('APP_CHANNEL')
bc.postMessage({ type: 'AUTHENTICATED' })
/**
* This means the parent window has authenticated has successfully refetched user, then we can close the popup.
*/
bc.onmessage = (event) => {
if (event.data.type === 'AUTHENTICATED_ACK') {
window.close()
}
}
}
static waitForAuthenticationAck() {
return new Promise<void>((resolve) => {
const bc = new BroadcastChannel('APP_CHANNEL')
bc.onmessage = async (event) => {
if (event.data.type === 'AUTHENTICATED') {
resolve()
bc.postMessage({ type: 'AUTHENTICATED_ACK' })
}
}
})
}
}
/**
* Returns a function to be awaited in order to make sure the user is logged in.
* If not logged-in it opens a popup with the connection flow, the promise returned is resolved
* once logged-in.
*
* To be used in SDK scope.
*/
export function useEnsureAuth() {
const { isLoggedIn, ...other } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const startSSO = () => {
return new Promise<void>((resolve) => {
SdkReverseClient.waitForAuthenticationAck().then(async () => {
await other.refetch()
resolve()
})
const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,
width=400,height=900,left=100,top=100`
window.open(new URL('authenticate/', authUrl()).href, '', params)
})
}
const ensureAuth = async () => {
if (!isLoggedIn) {
await startSSO()
}
}
return { ensureAuth }
}
@@ -0,0 +1,103 @@
import { Button } from '@/primitives/Button'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useState } from 'react'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { VisioIcon } from '@/assets/VisioIcon'
import { generateRoomId, useCreateRoom } from '../../rooms'
import {
ClientMessageType,
SdkReverseClient,
useEnsureAuth,
} from '../SdkReverseClient'
export const SdkCreateButton = () => {
const { t } = useTranslation('sdk', { keyPrefix: 'createButton' })
const [roomUrl, setRoomUrl] = useState<string>()
const [isLoading, setIsLoading] = useState(false)
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const { ensureAuth } = useEnsureAuth()
const submitCreateRoom = async () => {
setIsLoading(true)
const slug = generateRoomId()
const data = await createRoom({ slug, username })
const roomUrlTmp = getRouteUrl('room', data.slug)
setRoomUrl(roomUrlTmp)
setIsLoading(false)
SdkReverseClient.post(ClientMessageType.ROOM_CREATED, {
url: roomUrlTmp,
})
}
const submit = async () => {
await ensureAuth()
submitCreateRoom()
}
return (
<div
className={css({
paddingTop: '3px',
paddingLeft: '3px',
})}
>
{roomUrl ? (
<RoomUrl roomUrl={roomUrl} />
) : (
<Button
variant="primaryDark"
aria-label={t('label')}
onPress={submit}
data-attr="sdk-create"
loading={isLoading}
icon={<VisioIcon />}
>
{t('label')}
</Button>
)}
</div>
)
}
const RoomUrl = ({ roomUrl }: { roomUrl: string }) => {
const [isCopied, setIsCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(roomUrl!)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
}
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
color: 'greyscale.600',
})}
>
{roomUrl}
</span>
<Button
variant={isCopied ? 'success' : 'quaternaryText'}
data-attr="sdk-create-copy"
onPress={copy}
square
>
{isCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
</div>
)
}
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from 'react'
/**
* If value stays truthy for more than waitFor ms, syncValue takes the value of value.
* @param value
* @param waitFor
* @returns
*/
export function useSyncAfterDelay<T>(value: T, waitFor: number = 300) {
const valueRef = useRef(value)
const timeoutRef = useRef<NodeJS.Timeout>()
const [syncValue, setSyncValue] = useState<T>()
useEffect(() => {
valueRef.current = value
if (value) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
setSyncValue(valueRef.current)
timeoutRef.current = undefined
}, waitFor)
}
} else {
setSyncValue(value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return syncValue
}
+12 -1
View File
@@ -242,7 +242,7 @@ export const Footer = () => {
{t('links.data')}
</A>
</StyledLi>
<StyledLi>
<StyledLi divider>
<A
externalIcon
underline={false}
@@ -255,6 +255,17 @@ export const Footer = () => {
{t('links.accessibility')}
</A>
</StyledLi>
<StyledLi>
<A
externalIcon
underline={false}
footer="minor"
href="https://github.com/numerique-gouv/meet/"
aria-label={t('links.code') + ' - ' + t('links.ariaLabel')}
>
{t('links.code')}
</A>
</StyledLi>
</SecondRow>
<ThirdRow>
{t('mentions')}{' '}
+4 -2
View File
@@ -22,7 +22,8 @@
},
"logout": "",
"notFound": {
"heading": ""
"heading": "",
"body": ""
},
"submit": "OK",
"footer": {
@@ -34,7 +35,8 @@
"legalsTerms": "",
"data": "",
"accessibility": "",
"ariaLabel": ""
"ariaLabel": "",
"code": ""
},
"mentions": "",
"license": ""
@@ -6,5 +6,14 @@
"raised": {
"description": "",
"cta": ""
},
"muted": "",
"openChat": "",
"lowerHand": {
"auto": "",
"dismiss": ""
},
"reaction": {
"description": ""
}
}
+32 -1
View File
@@ -19,6 +19,11 @@
"label": ""
},
"heading": "",
"effects": {
"description": "",
"title": "",
"subTitle": ""
},
"joinLabel": "",
"joinMeeting": "",
"toggleOff": "",
@@ -81,7 +86,12 @@
"closed": ""
},
"support": "",
"moreOptions": ""
"moreOptions": "",
"reactions": {
"button": "",
"send": "",
"you": ""
}
},
"options": {
"buttonLabel": "",
@@ -102,11 +112,17 @@
"notAvailable": "",
"heading": "",
"blur": {
"title": "",
"light": "",
"normal": "",
"apply": "",
"clear": ""
},
"virtual": {
"title": "",
"apply": "",
"clear": ""
},
"experimental": ""
},
"sidePanel": {
@@ -168,6 +184,7 @@
"muteYourself": "",
"muteParticipant": "",
"muteParticipantAlert": {
"heading": "",
"description": "",
"confirm": "",
"cancel": ""
@@ -178,5 +195,19 @@
},
"recording": {
"label": ""
},
"participantTileFocus": {
"pin": {
"enable": "",
"disable": ""
},
"effects": "",
"muteParticipant": "",
"fullScreen": ""
},
"fullScreenWarning": {
"message": "",
"stop": "",
"ignore": ""
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"createButton": {
"label": ""
}
}
+4 -2
View File
@@ -22,7 +22,8 @@
},
"logout": "Logout",
"notFound": {
"heading": "Page not found"
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
},
"submit": "OK",
"footer": {
@@ -34,7 +35,8 @@
"legalsTerms": "Legal Notice",
"data": "Personal Data and Cookies",
"accessibility": "Accessibility: audit in progress",
"ariaLabel": "new window"
"ariaLabel": "new window",
"code": "Open Source Code Repository"
},
"mentions": "Unless otherwise stated, the contents of this site are available under",
"license": "etalab 2.0 license"
@@ -6,5 +6,14 @@
"raised": {
"description": "{{name}} has raised their hand.",
"cta": "Open waiting list"
},
"muted": "{{name}} has muted your microphone. No participant can hear you.",
"openChat": "Open chat",
"lowerHand": {
"auto": "It seems you have started speaking, so your hand will be lowered.",
"dismiss": "Keep hand raised"
},
"reaction": {
"description": "{{name}} reacted with {{emoji}}"
}
}
+30 -3
View File
@@ -20,7 +20,8 @@
},
"effects": {
"description": "Apply effects",
"title": "Effects"
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
"heading": "Join the meeting",
"joinLabel": "Join",
@@ -84,7 +85,12 @@
"closed": "Show AI assistant"
},
"support": "Support",
"moreOptions": "More options"
"moreOptions": "More options",
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"you": "you"
}
},
"options": {
"buttonLabel": "More Options",
@@ -102,14 +108,20 @@
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
"notAvailable": "Video effects will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
"heading": "Blur",
"blur": {
"title": "Background blur",
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"virtual": {
"title": "Virtual background",
"apply": "Enable virtual background",
"clear": "Disable virtual background"
},
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
},
"sidePanel": {
@@ -171,6 +183,7 @@
"muteYourself": "Close your mic",
"muteParticipant": "Close the mic of {{name}}",
"muteParticipantAlert": {
"heading": "Mute {{name}}",
"description": "Mute {{name}} for all participants? {{name}} will be the only one who can unmute themselves.",
"confirm": "Mute",
"cancel": "Cancel"
@@ -181,5 +194,19 @@
},
"recording": {
"label": "Recording"
},
"participantTileFocus": {
"pin": {
"enable": "Pin",
"disable": "Unpin"
},
"effects": "Apply visual effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
"fullScreenWarning": {
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting",
"ignore": "Ignore"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"createButton": {
"label": "Create a Visio link"
}
}
+4 -2
View File
@@ -22,7 +22,8 @@
},
"logout": "Se déconnecter",
"notFound": {
"heading": "Page introuvable"
"heading": "Vérifier votre code de réunion",
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
},
"submit": "OK",
"footer": {
@@ -34,7 +35,8 @@
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilités : audit en cours",
"ariaLabel": "nouvelle fenêtre"
"ariaLabel": "nouvelle fenêtre",
"code": "Dépôt de code Open Source"
},
"mentions": "Sauf mention contraire, les contenus de ce site sont disponibles sous",
"license": "licence etalab 2.0"
@@ -6,5 +6,14 @@
"raised": {
"description": "{{name}} a levé la main.",
"cta": "Ouvrir la file d'attente"
},
"muted": "{{name}} a coupé votre micro. Aucun participant ne peut l'entendre.",
"openChat": "Ouvrir le chat",
"lowerHand": {
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
"dismiss": "Laisser la main levée"
},
"reaction": {
"description": "{{name}} a reagi avec {{emoji}}"
}
}
+30 -3
View File
@@ -21,7 +21,8 @@
"heading": "Rejoindre la réunion",
"effects": {
"description": "Appliquer des effets",
"title": "Effets"
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
@@ -84,7 +85,12 @@
"closed": "Afficher l'assistant IA"
},
"support": "Support",
"moreOptions": "Plus d'options"
"moreOptions": "Plus d'options",
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"you": "vous"
}
},
"options": {
"buttonLabel": "Plus d'options",
@@ -102,14 +108,20 @@
},
"effects": {
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
"notAvailable": "Les effets vidéo seront bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
"heading": "Flou",
"blur": {
"title": "Flou d'arrière-plan",
"light": "Léger flou",
"normal": "Flou",
"apply": "Appliquer le flou",
"clear": "Désactiver le flou"
},
"virtual": {
"title": "Arrière-plan virtuel",
"apply": "Appliquer l'arrière plan virtuel",
"clear": "Désactiver l'arrière plan virtuel"
},
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
},
"sidePanel": {
@@ -171,6 +183,7 @@
"muteYourself": "Couper votre micro",
"muteParticipant": "Couper le micro de {{name}}",
"muteParticipantAlert": {
"heading": "Couper le micro de {{name}}",
"description": "Couper le micro de {{name}} pour tous les participants ? {{name}} est la seule personne habilitée à réactiver son micro",
"confirm": "Couper le micro",
"cancel": "Annuler"
@@ -181,5 +194,19 @@
},
"recording": {
"label": "Enregistrement"
},
"participantTileFocus": {
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Appliquer des effets",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
"fullScreenWarning": {
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation",
"ignore": "Ignorer"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"createButton": {
"label": "Créer un lien Visio"
}
}
+1 -1
View File
@@ -41,7 +41,7 @@
},
"settingsButtonLabel": "Paramètres",
"tabs": {
"account": "Profile",
"account": "Profil",
"audio": "Audio",
"general": "Général",
"notifications": "Notifications"
+8 -2
View File
@@ -6,12 +6,15 @@ import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
} & {
icon?: ReactNode
}
export const Button = ({
@@ -20,13 +23,16 @@ export const Button = ({
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={buttonRecipe(variantProps)}
{...(componentProps as RACButtonsProps)}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
+45
View File
@@ -0,0 +1,45 @@
import { cva } from '@/styled-system/css'
const loader = cva({
base: {
borderRadius: '50%',
position: 'relative',
animation: 'rotate 1s linear infinite',
'&:before, &:after': {
content: '""',
boxSizing: 'border-box',
position: 'absolute',
inset: '0',
borderRadius: '50%',
borderStyle: 'solid',
borderColor: 'white',
},
_before: {
animation: 'prixClipFix 2s linear infinite',
},
_after: {
borderColor: 'white',
animation:
'prixClipFix 2s linear infinite, rotate 0.5s linear infinite reverse',
inset: 6,
},
},
variants: {
size: {
small: {
width: '24px',
height: '24px',
'&:before, &:after': {
borderWidth: '2px',
},
},
},
},
defaultVariants: {
size: 'small',
},
})
export const Loader = () => {
return <div className={loader()}></div>
}
+3 -1
View File
@@ -9,15 +9,17 @@ import { Box } from './Box'
export const Menu = ({
children,
variant = 'light',
placement,
}: {
children: [trigger: ReactNode, menu: ReactNode]
variant?: 'dark' | 'light'
placement?: 'bottom' | 'top' | 'left' | 'right'
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
+13
View File
@@ -28,6 +28,14 @@ export const text = cva({
paddingTop: 'heading',
},
},
subTitle: {
fontSize: '1rem',
color: 'greyscale.600',
},
bodyXsBold: {
textStyle: 'body',
fontWeight: 'bold',
},
body: {
textStyle: 'body',
},
@@ -84,6 +92,11 @@ export const text = cva({
marginBottom: 0.5,
},
},
fullWidth: {
true: {
width: '100%',
},
},
last: {
true: {
marginBottom: '0!',
+1 -1
View File
@@ -27,7 +27,7 @@ export const ToggleButton = ({
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
@@ -8,6 +8,10 @@ export const buttonRecipe = cva({
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
'&[data-disabled]': {
cursor: 'default',
},
gap: '0.5rem',
},
variants: {
size: {
@@ -86,6 +90,27 @@ export const buttonRecipe = cva({
backgroundColor: 'greyscale.100/50',
},
},
bigSquare: {
width: '56px',
height: '56px',
borderColor: 'greyscale.200',
borderRadius: '4px',
backgroundColor: 'greyscale.50',
padding: '0',
flexShrink: 0,
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
},
transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': {
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
},
'&[data-disabled]': {
backgroundColor: 'greyscale.100',
color: 'greyscale.400',
opacity: '0.7',
},
},
tertiary: {
backgroundColor: 'primary.100',
color: 'primary.800',
@@ -152,6 +177,18 @@ export const buttonRecipe = cva({
color: 'primaryDark.100',
},
},
quaternaryText: {
backgroundColor: 'transparent',
color: 'greyscale.600',
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
color: 'greyscale.700',
},
'&[data-pressed]': {
backgroundColor: 'greyscale.100',
color: 'greyscale.700',
},
},
greyscale: {
backgroundColor: 'transparent',
color: 'greyscale.400',
@@ -242,6 +279,9 @@ export const buttonRecipe = cva({
width: 'full',
},
},
loading: {
true: {},
},
// some toggle buttons make more sense without a "pushed button" style when selected because their content changes to mark the state
shySelected: {
true: {},
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
enabled: boolean
}
export const ScreenSharePreferenceStore = proxy<State>({
enabled: true,
})
+16 -1
View File
@@ -14,6 +14,7 @@ const DEFAULT_STATE: State = {
new Map([
[NotificationType.ParticipantJoined, true],
[NotificationType.HandRaised, true],
[NotificationType.MessageReceived, true],
])
),
soundNotificationVolume: 0.1,
@@ -24,7 +25,21 @@ function getNotificationsState(): State {
const stored = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored, deserializeToProxyMap)
return parsed || DEFAULT_STATE
// Ensure all default notification types exist in the recovered state
return {
...DEFAULT_STATE,
...parsed,
soundNotifications: proxyMap(
new Map(
Array.from(DEFAULT_STATE.soundNotifications.keys()).map((key) => [
key,
parsed.soundNotifications.has(key)
? parsed.soundNotifications.get(key)
: DEFAULT_STATE.soundNotifications.get(key),
])
)
),
}
} catch (error: unknown) {
console.error(
'[NotificationsStore] Failed to parse stored settings:',
@@ -1,3 +1,4 @@
{{ if .Values.secrets }}
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
@@ -31,3 +32,4 @@ spec:
webhook:
url: "http://bitwarden-cli-{{ $.Release.Namespace }}.{{ $.Release.Namespace }}.svc.cluster.local:8087/object/attachment/{{`{{ .remoteRef.property }}`}}?itemid={{`{{ .remoteRef.key }}`}}"
result: {}
{{ end }}
@@ -1,3 +1,4 @@
{{ if .Values.secrets }}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
@@ -26,3 +27,4 @@ spec:
key: {{ .itemId }}
property: {{ .field }}
{{- end }}
{{ end }}
@@ -1,3 +1,4 @@
{{ if .Values.secrets }}
---
apiVersion: apps/v1
kind: Deployment
@@ -90,3 +91,4 @@ spec:
selector:
app.kubernetes.io/name: bitwarden-cli
app.kubernetes.io/instance: bitwarden-cli
{{ end }}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.1
version: 0.0.3
+4
View File
@@ -82,6 +82,7 @@
| `backend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `backend.extraVolumeMounts` | Additional volumes to mount on the backend. | `[]` |
| `backend.extraVolumes` | Additional volumes to mount on the backend. | `[]` |
| `backend.pdb.enabled` | Enable pdb on backend | `true` |
### frontend
@@ -131,6 +132,7 @@
| `frontend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `frontend.extraVolumeMounts` | Additional volumes to mount on the frontend. | `[]` |
| `frontend.extraVolumes` | Additional volumes to mount on the frontend. | `[]` |
| `frontend.pdb.enabled` | Enable pdb on frontend | `true` |
### posthog
@@ -208,6 +210,7 @@
| `summary.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `summary.extraVolumeMounts` | Additional volumes to mount on the summary. | `[]` |
| `summary.extraVolumes` | Additional volumes to mount on the summary. | `[]` |
| `summary.pdb.enabled` | Enable pdb on summary | `true` |
### celery
@@ -255,3 +258,4 @@
| `celery.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `celery.extraVolumeMounts` | Additional volumes to mount on the celery. | `[]` |
| `celery.extraVolumes` | Additional volumes to mount on the celery. | `[]` |
| `celery.pdb.enabled` | Enable pdb on celery | `false` |
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.backend.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.celery.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}

Some files were not shown because too many files have changed in this diff Show More