Compare commits

..

80 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM 51a1725c47 wip 2024-10-12 22:49:35 +02:00
lebaudantoine 1875a394c6 ♻️(frontend) simplify button primitive
In object-oriented terms, the previous implementation violated the Liskov
Substitution Principle. Props between these two components (Button and Link)
were not substitutable.

This led to TypeScript errors and increased overall complexity without
significant DX gains. To address this, the LinkButton has been extracted
into a dedicated component.
2024-10-09 16:40:59 +02:00
renovate[bot] 211ba332dc ⬆️(dependencies) update js dependencies 2024-10-09 16:40:59 +02:00
renovate[bot] 7333c21632 ⬆️(dependencies) update vite to v5.4.6 [SECURITY] 2024-10-09 15:09:59 +02:00
lebaudantoine 78ebd1a8fd 👷(ci) update build push action to v6
Update the build push action.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 682b69fc11 🔒️(backend) migrate backend image to alpine
Enhancement made by @rouja while working on the vulnerabilities
found by Trivy scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 7a73bf8fc2 💚(frontend) fix frontend image vulnerabilities
Fixed vulnerabilities found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 1e934957f5 💚(backend) fix backend image vulnerabilities
Fixed vulnerabilities with setup tools found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 5a7584a3ad 👷(ci) scan for vulnerabilities on Docker images
Configure Trivy Scan in the CI to detect vulnerabilities on our
Docker image. Enhance stack security.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL fb9bf6b08e 🔐(tilt) add Samuel's key
Add Samuel's key to handle secret.
2024-10-09 11:14:11 +02:00
Jacques ROUSSEL 11b8541005 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-27 18:46:26 +02:00
lebaudantoine 64607ae8d0 (frontend) introduce push to talk on microphone
I am not a huge fan of changing the component's behavior based on
a if, but that the only way I found to have something quickly.

Actually, the push to talk feature will always only applies to the
microphone. No other devices will be concerned.

Reuse the active speaker indicator to quickly bootstrap the feature.
Some details should be improved in terms of UI. The UX is quite
decent.
2024-09-27 16:34:32 +02:00
lebaudantoine c403bbc347 ♻️(frontend) extract toggle device component
Encapsulate toggle logic in a dedicated component.
Prepare the introduction of the push to talk feature.
2024-09-27 16:34:32 +02:00
lebaudantoine 62855fe12c (frontend) introduce active speaker pushToTalk variant
Will be used to indicate when the push to talk is open.
It gives the user a feedback wether or not her mic is active.
2024-09-27 16:34:32 +02:00
lebaudantoine f27d451eb6 ♻️(frontend) make active speaker keyframe relative to height
Keyframe can now be reused in various context, when the children
size are not fixed to 4px.
2024-09-27 16:34:32 +02:00
lebaudantoine 68792d8019 ✏️(frontend) fix typo in active speaker keyframe
Found a typo and fixed it.
2024-09-27 16:34:32 +02:00
lebaudantoine ebb8c14eeb ♻️(frontend) register shortcut using a hook
Encapsulate the logic for shortcuts registering in a proper hook.
It makes the code reusable and easier to read.
2024-09-27 16:34:32 +02:00
lebaudantoine 15d43b8d5e ♻️(frontend) handle proper shortcut object
Created a type to properly manipulate any data related to a
shortcut. Make the code more concise.
2024-09-27 16:34:32 +02:00
lebaudantoine 1faae96ccd (frontend) introduce long press keyboard shortcut
Needed to support push to talk using the spacebar.
Introduce a utility hook. Will be called by the mic toggle
in the upcoming commits.

Inspired by Jitsi code.
2024-09-27 16:34:32 +02:00
lebaudantoine 651cc0e5bd (frontend) introduce keyboard shortcut
Inspired by Gmeet for the UX and by Jitsi for the code.
Naive implementation of Keyboard shortcuts listener.

Will be enhanced in the upcoming commits.
2024-09-27 16:34:32 +02:00
lebaudantoine 0dadd472ff 🔖(patch) bump release to 0.1.7
Bump release to 0.1.7
2024-09-25 20:05:29 +02:00
lebaudantoine be35ee0258 🐛(frontend) prevent side panel and chat from opening simultaneously
Fixed issue where side panel could open while chat was already open.
Resolved recent bug introduced in a previous commit.
2024-09-25 19:53:48 +02:00
lebaudantoine bece79f47b ♻️(frontend) increase silent login attempts to every 5 minutes
Maximize logged-in users. This ensures that users remain authenticated
more consistently across sessions without manual intervention.
2024-09-25 14:45:09 +02:00
lebaudantoine a499331960 🐛(frontend) prevent silent login during active calls
React Query refetches stale data on window focus, which triggers
silent login attempts for logged-out users.

If user data becomes stale (after 1 hour), silent login redirects
the user to the auth domain, potentially disconnecting them during calls.

To prevent this, user data is now considered non-stale
during active sessions.

If the user logs in via another tab, a manual page
reload will be required to refresh their session.
2024-09-25 14:45:09 +02:00
lebaudantoine d50d167d0a 🐛(helm) fix certificates configuration
Newly introduced ingress for PostHog were misconfigured, oopsie.
Here is the fix.
2024-09-25 11:58:34 +02:00
lebaudantoine e4c7bc0826 ♻️(helm) separate PostHog ingress
Based on feedback from @rouja, I've updated the Helm configuration for PostHog
to use separate ingress resources for each service. Although the documentation
suggests sharing the same ingress, the services have different externalName
values, which conflicts with the use of a vhost in the ingress annotations.
This change ensures proper service redirection by aligning each service with
its own ingress.
2024-09-25 11:40:44 +02:00
lebaudantoine b5244a5ec0 🔧(helm) configure support and analytics
Declare the expected support and analytics env variables
expected by the frontend for each environment. To avoid consuming
too much credits from our PostHog free tier plan.
2024-09-25 11:40:44 +02:00
lebaudantoine 8cc2cc83c6 (frontend) initialize application from remote configs
Query API to get the App's configs.
Replacement for env variables, thus with a single image,
we can dynamically update frontend's behavior.

Code inspired by Marsha. Not sure having a single component
returning null is a good idea, to be discussed.
2024-09-25 11:40:44 +02:00
lebaudantoine e591d09b00 (backend) add endpoint for frontend's configuration
Inspired by Joanie.

In Frontend context, env variables are only available at build time,
not runtime. This is one of the easiest way to pass frontend dynamic
configurations while running.

This commit only exposes the view already existing.
2024-09-25 11:40:44 +02:00
Jacques ROUSSEL eeb4dae12d 💚(ci) fix argocd job which handles sync
Fixed by @rouja. ArgoCD should be more robust than ever, while
syncing with our code.
2024-09-25 11:40:44 +02:00
lebaudantoine 271389d459 🚨(helm) fix linter errors
Initial set-up errors. Fixed them. Removed unused Preprod environment,
as secrets are not configured yet, it raises an error.
2024-09-25 11:40:44 +02:00
Jacques ROUSSEL fe6eefa1f0 👷(ci) lint helmfile
Introduced by @rouja. Added a new linter to ensure helm and yaml
files can be properly parsed into templates.
ArgoCD can not break anymore.
2024-09-25 11:40:44 +02:00
lebaudantoine a276517278 📈(frontend) setup a reverse proxy for analytics
Proxy analytics requests through our backend to minimize
ad-blockers impact. I configured the Helm Charts following
PostHog official documentation.
2024-09-25 11:40:44 +02:00
renovate[bot] 9e9b9015f4 ⬆️(dependencies) update js dependencies 2024-09-24 10:49:28 +02:00
lebaudantoine 3f0378aada ♻️(frontend) remove Tchap support
Tchap is no longer relevant due to the integration of Crisp.
The Tchap channel will be deleted.
2024-09-24 10:49:14 +02:00
Jacques ROUSSEL 90c88a8bd3 🔒️(helm) change domainon production
Add ingress in order to migrate from meet.numerique.gouv.fr to
visio.numerique.gouv.fr
2024-09-23 20:20:56 +02:00
lebaudantoine 1103902c12 🔖(minor) bump release to 0.1.6
Release to 0.1.6
2024-09-23 20:10:16 +02:00
lebaudantoine 812016d09c (frontend) add user support feature using Crisp
Implement a chat feature that allows users to communicate directly with an
engineer for assistance with issues. We use Crisp for consistency, as it’s
already utilized in all other LaSuite products. Additionally, we need to
configure an environment variable in the frontend for better flexibility.

This is the initial implementation, and session handling will be refined
in future updates.
2024-09-23 20:06:29 +02:00
lebaudantoine cadc5c0034 (frontend) add crisp-sdk-web dependency
La Suite uses Crisp for Chat support.
Added their react sdk.
2024-09-23 20:06:29 +02:00
lebaudantoine b083d837f8 ♻️(frontend) encapsulate PostHog logic in dedicated functions
Decouple the code from the PostHog dependency by wrapping it in a custom hook
and several utility functions. This enhances code maintainability and separation
of concerns.
2024-09-23 18:58:38 +02:00
lebaudantoine db8445f4ab 🐛(backend) add missing Django settings
Fixed an issue where a setting cloned from Joanie was missing
in the  Django configuration. Although its value was provided, it was
not applied due to the missing reference in the settings file.
2024-09-23 18:12:44 +02:00
lebaudantoine 0e5c2be445 (frontend) replace login link by ProConnect button
Following their documentation, replace our generic login button,
by a more specific one. Asked by several users.
2024-09-23 17:31:07 +02:00
lebaudantoine 0131a65509 ✏️(frontend) fix minors typos in i18n
Found and fixed minor typos in our copywritting.
2024-09-23 16:34:11 +02:00
lebaudantoine fb3727e73e 👔(frontend) rename 'meet' to 'visio'
In alignment with French government guidelines,
ensuring no English terms slip through.
2024-09-23 16:34:11 +02:00
Jacques ROUSSEL 3391165e4b 🔒️(helm) change domain
Change the domain to visio-staging.beta.numerique.gouv.fr
2024-09-23 12:07:13 +02:00
Jacques ROUSSEL 0be94aa572 🔒️(helm) setup temporary redirect
Add a specific certificate to prepare redirect
2024-09-23 12:07:13 +02:00
lebaudantoine b309f91095 💡(frontend) highlight technical debt
We should use participant attributes, rather than metadata, to store hand
status (raised/lowered). However, the useParticipantInfo hook is currently
not suited for this, as it does not refresh when attributes change.
2024-09-22 20:02:33 +02:00
lebaudantoine c50a749293 ♻️(frontend) simplify raised hand notification
Listen to metadata changed events instead of messaging the whole
room. Less code, might be less clean, to be discussed.
2024-09-22 20:02:33 +02:00
lebaudantoine cd9e7c8a1f 🚸(frontend) inform user Blurring effects are experimental
Be transparent with users: this version lacks quality and is currently
limited to Chrome, which may lead to frustration.

In addition to providing information, we should implement a method to
collect user feedback and reactions.
2024-09-22 20:02:33 +02:00
lebaudantoine 9d0767ccfe ♻️(frontend) refactor useParticipants to streamline updatesOnlyOn logic
Simplified the logic to avoid redundancy. Removed unnecessary duplication of
eventGroups, as it's already exported from the core. My bad for the initial
complication — code is now cleaner.
2024-09-22 20:02:33 +02:00
lebaudantoine 021e52d9eb 💄(frontend) organize blurring options
Add visual container and a heading to indicate buttons are
blurring options.
2024-09-22 20:02:33 +02:00
lebaudantoine 607a5fc99d (frontend) introduce blurring effects for Chromium-based users
Implemented using MediaPipe, which is not supported on Firefox due to
limitations outlined in issue #38 of the track-processors-js repo.

We decided to release this first version exclusively for Chrome to quickly
offer a solution to users. Future iterations will address broader compatibility.

An informational message will be added to notify users that the feature is
experimental. For now, a text label will be used in place of an icon.
2024-09-22 20:02:33 +02:00
lebaudantoine 756be17cc7 (frontend) introduce a side panel for effects
It simply renders the video track if enabled. It's a basis
for building the 'blur your screen' feature.

More in the upcoming commits.
2024-09-22 20:02:33 +02:00
lebaudantoine 00fa7beebd ♻️(frontend) introduce a side panel
Refactor side panel into a reusable component to display any interactive
content like menus, messages, participant lists, or effects. Establish it
as a core feature of the videoconference tool.

Improve extensibility and better share responsibilities. The next step is to
refactor the chat to render inside the side panel.
2024-09-22 20:02:33 +02:00
lebaudantoine b9d13de591 (frontend) introduce 'effects' menu item
Increased all icons size, as they looked a bit small on screen.
Introduce a new component separator, to organize menu items
in section.

Work in progress, added the 'effects' item, which triggers no
action yet.
2024-09-22 20:02:33 +02:00
lebaudantoine 7896890ffc ♻️(frontend) refactor translation keys to use prefix
Code cleaning. Avoid duplication.
2024-09-22 20:02:33 +02:00
lebaudantoine a2043abb51 (frontend) install track-processors-js from LiveKit
Recommended dependency to process video tracks, to add a virtual
background, or a blurring effect. Created and maintained by the
core LiveKit's team.

Please note, few issues are known. track-processors-js relies on
MediaPipe (by Google), which is not supported on Firefox …

Quick-win to ship a first draft.
2024-09-22 20:02:33 +02:00
rouja 64bb1b3bb5 Merge pull request #161 from numerique-gouv/fix-secret-mep-issue
🔒️(helm) fix secret sync precedence
2024-09-20 15:04:29 +02:00
Jacques ROUSSEL a1a56402d1 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-20 14:55:15 +02:00
lebaudantoine 582d3774a4 ⬆️(frontend) fix persistent choices nesting issue
Upgraded to livekit/components-react v2.5.4, fixing a bug in v2.5.3 that
caused repeated nesting of persistent user choices, exhausting the quota.
Thanks to Lukas for the quick fix.
2024-09-17 19:43:40 +02:00
lebaudantoine fab08cfaf8 🚑️(frontend) fix posthog user's ID
Quotes were misplaced, leading to the same ID being generated for every user.
Fixed the issue, which was a trivial error.
2024-09-17 18:26:53 +02:00
lebaudantoine 550c48f29e 📈(frontend) track room join events with room slug
Focus on join events as a core metric for user room participation.
Added properties to differentiate events by room slug.
2024-09-17 15:28:52 +02:00
lebaudantoine 0c64580abf 📈(frontend) track page view events in SPA
Implement page view tracking in the SPA following PostHog’s guidelines.
Manually capture page's location when it changes.
2024-09-17 15:28:52 +02:00
lebaudantoine 0ad9b7e233 📈(frontend) limit event tracking to stay within free tier limits
Optimize event tracking to be frugal and reduce event volume,
even though filtering can be done later if needed.
2024-09-17 15:28:52 +02:00
lebaudantoine b9bcc45cce 📈(frontend) add explicit data attributes for key event tracking
PostHog's autocapture is useful, but explicit tracking ensures cleaner, more
focused data. This initial pass targets high-impact actions; we'll iterate as
needed.
2024-09-17 15:28:52 +02:00
lebaudantoine bcd285e368 ♻️(frontend) refactor tooltip on screen share toggle
Reorganize tooltip to follow others toggle buttons.
2024-09-17 15:28:52 +02:00
lebaudantoine c0ad98eb34 ♻️(frontend) refactor keyprefix in translation
Factorized translations' prefix.
2024-09-17 15:28:52 +02:00
lebaudantoine 178278235e (frontend) login and logout from Posthog
Following Posthog documentation, login and logout users directly
from the client. Note: Logout function should be encapsulate in
a proper function.
2024-09-17 15:28:52 +02:00
lebaudantoine 11664956c7 (frontend) initialize posthog
Initialize Posthog script with our project's keys.
This key is public, it's not a secret.

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

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