Compare commits

..

78 Commits

Author SHA1 Message Date
lebaudantoine c86a47f736 ⬆️(backend) update joserfc to 1.6.8 to fix CVE-2026-49852
joserfc versions before 1.6.8 accept an empty or nil HMAC key when
verifying HS256/HS384/HS512 tokens, which is a cross-language
sibling of CVE-2026-45363.

Bump joserfc from 1.6.4 to 1.6.8 to pick up the fix.

Ref: https://avd.aquasec.com/nvd/cve-2026-49852
2026-07-03 17:32:46 +02:00
lebaudantoine dd6b4512c8 🔖(minor) bump release to 1.22.0 2026-07-03 17:32:46 +02:00
lebaudantoine f82fd4bece 🔖(chart) release chart 0.0.26
Fix posthog ingresses.
2026-07-03 14:34:39 +02:00
lebaudantoine edab18d94a 🩹(helm) fix Helm ingress rendering when passing multiple hosts
Passing a list of hosts in the ingress was broken: the template
helpers were called with the current loop context (`.`) instead of
the root context (`$`), so the service name was rendered incorrectly
inside the hosts loop.
2026-07-03 14:34:39 +02:00
Cyril 636c2168be 🩹(frontend) allow fullscreen share warning interaction in PiP
Enable PiP fullscreen share warning buttons; drop `inert` from StageFrame
2026-07-03 11:31:36 +02:00
Cyril d54e9c2ad0 ♻️(frontend) autofocus stop button in fullscreen share warning
Replace the focus effect and ref with autoFocus on the stop button.
2026-07-03 11:31:36 +02:00
lebaudantoine 657712d7cb (backend) track meeting link generation events
Track events whenever a new meeting link is generated, both through
the public API and through the external API.

The goal is twofold:

* Identify where the most links are generated from, so we can assess
  which integration or entry point works best.
* Measure how many links are generated per user, so we can consider
  a user truly active when they generate a link, rather than only
  when they participate in a meeting.
2026-07-02 17:01:46 +02:00
lebaudantoine d2bfbee389 🩹(backend) fix client_id retrieval from request.auth
request.auth is a dict, not an object, so the getattr call was
failing when trying to retrieve client_id. Access it as a dict key
instead.
2026-07-02 17:01:46 +02:00
lebaudantoine 9ba97fd14f (backend) introduce a configurable analytics system
Add an analytics abstraction that allows configuring which analytics
solution the app uses. PostHog is implemented as one backend, but by
default no analytics backend is activated.

The goal is to track events in a sufficiently organized way and to
let any developer implement their own backend as long as it follows
the same protocol.
2026-07-02 17:01:46 +02:00
lebaudantoine be0d0927d4 (backend) install PostHog SDK in the backend
Add the PostHog SDK to the backend so we can send analytics events
from server-side code. We currently lack data on events triggered
from the backend, and this closes that gap.
2026-07-02 17:01:46 +02:00
ilias 27dce44d40 (backend) cover encoded S3 keys with plus signs
Add a regression test for already encoded S3 notification keys that contain plus signs in the prefix.
2026-07-02 12:23:31 +02:00
ilias 78acaf395e 🐛(backend) normalize raw S3 object keys
Correction to preserve already encoded plus signs in addition to slashes.
2026-07-02 12:23:31 +02:00
ilias 4e5e648730 (backend) add parser test docstrings
Add docstrings to the S3 parser tests
2026-07-02 12:23:31 +02:00
ilias 924fe95d94 📝(docs) update changelog for S3 notification key compatibility 2026-07-02 12:23:31 +02:00
ilias e3e33c7d0a 🐛(backend) normalize S3 notification object keys 2026-07-02 12:23:31 +02:00
lebaudantoine 16ee575ff8 (all) support a dedicated domain for the PostHog feature flag API
Ad blockers recently started blocking requests to our PostHog
feature flag API, leading to undesired behavior in the app.

Following PostHog's documentation, allow configuring a dedicated
domain for feature flags, isolated from the main PostHog domain.
2026-07-01 11:48:47 +02:00
lebaudantoine e42b083f20 🛂(backend) reject user access tokens on the API
The original implementation, introduced two years ago, was incorrect
and exposed the API to an undesired authentication mode: any user
access token obtained for a given user was being accepted as valid
credentials on the external API.

Restrict authentication to the intended mode so that user access
tokens are no longer accepted on this API.

Thanks @lunika spotting this.
2026-07-01 11:24:59 +02:00
snyk-bot 6d2c31eb0a fix: upgrade @pandacss/preset-panda from 1.11.1 to 1.11.3
Snyk has created this PR to upgrade @pandacss/preset-panda from 1.11.1 to 1.11.3.

See this package in npm:
@pandacss/preset-panda

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-06-30 18:56:32 +02:00
Cyril e9bdf173de 🩹(frontend) enable screen share button in PiP
Enable screen share button in PiP by passing showScreenShare to PipControlBar
2026-06-30 16:18:05 +02:00
leo dd23ce817a 📝(agents) add missing metadata_collector.dist environment template
PR #1446 introduced compose setup cleanup but ommited the
`metadata_collector.dist` environment template. Add this new file
to the repository.
2026-06-30 15:09:20 +02:00
lebaudantoine 1ca8f6e5ea 📝(doc) add email.eu to the list of known instances
List email.eu as one of the known instances running La Suite Meet.
2026-06-30 15:09:07 +02:00
Cyril f98e884067 (frontend) cap and paginate tiles in picture-in-picture
Cap PiP grid at 5 tiles per page with active-speaker priority and pagination.
2026-06-29 16:55:15 +02:00
Cyril b498926353 ️(frontend) improve pagination control accessibility
Turn main-window pagination into a labelled nav landmark with a live
page counter for screen readers. Add pagination.label in all locales.
2026-06-29 16:51:09 +02:00
lebaudantoine 3d4dc2d631 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
Following internal feedback, rename the "Premium" wording to
"Advanced" across the transcription and recording sidepanels, so the
label no longer implies a paid tier.
2026-06-29 16:38:54 +02:00
lebaudantoine 1523e6aec9 📝(docs) precise the French generalization by the PM
Add a note in the documentation about La Suite Meet being
generalized by the French Prime Minister, along with a link to an
English-language source article.
2026-06-29 13:31:32 +02:00
lebaudantoine 99510c9c6a 📝(docs) add Clever Cloud as a La Suite Meet SaaS provider
List Clever Cloud as one of the providers offering La Suite Meet as
a SaaS.
2026-06-29 13:31:32 +02:00
lebaudantoine 9f003e95f3 📝(docs) highlight community interaction earlier in the README
Add a tip explaining how to interact with the community
higher up in the README so it is more visible to newcomers.
2026-06-29 13:31:32 +02:00
lebaudantoine 46c30b6fcd 📝(docs) clarify contribution guidelines
Update the contribution documentation to make the conditions of
contribution to the project clearer.

Also encourage potential contributors to connect with the community
and get involved.
2026-06-29 13:31:32 +02:00
snyk-bot 1533ae8a3c fix: upgrade posthog-js from 1.379.2 to 1.382.0
Snyk has created this PR to upgrade posthog-js from 1.379.2 to 1.382.0.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-06-29 13:31:11 +02:00
leo 526e96797e ♻️(env) refactor env variables handling
Add missing variables and homogenize env settings between agents.
Create missing dist file for metadata collector.
2026-06-29 13:28:54 +02:00
Maarten Draijer 8903a55008 📝(docs) document rebranding the favicon via a volume mount
The favicon is bundled into the frontend image as static files served from
/usr/share/nginx/html. Rather than add a runtime config knob, document how to
overlay custom icons with a volume (ConfigMap + frontend.extraVolumes/
extraVolumeMounts). This serves the right icon from the first byte — no
rebuild, no favicon flash — and covers every variant, including the iOS
home-screen and Android/PWA icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 03:12:31 +02:00
snyk-bot 2a60b49086 fix: upgrade posthog-js from 1.376.4 to 1.379.2
Snyk has created this PR to upgrade posthog-js from 1.376.4 to 1.379.2.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-06-28 03:07:09 +02:00
leo aee1847303 (backend) add LiveKit egress_ended fallback for saving recordings
Recording lifecycle previously relied exclusively on the MinIO storage-hook
endpoint to transition from STOPPED to SAVED, which made the system dependent
on MinIO bucket notifications and lifecycle configuration. Introduce support
for LiveKit egress_ended webhook (EGRESS_COMPLETE, EGRESS_LIMIT_REACHED) as
 an alternative finalization mechanism for self-hosted deployments that do
 not use MinIO / S3 hooks.

Change behavior of existing configuation RECORDING_STORAGE_EVENT_ENABLE.
When the LiveKit mechanism is enabled (RECORDING_STORAGE_EVENT_ENABLE=False),
RecordingEventsService.handle_complete is triggered from
LiveKitEventsService._handle_egress_ended.
2026-06-24 20:51:58 +02:00
snyk-bot 6b7cd8ab2e fix: upgrade posthog-js from 1.376.0 to 1.376.4
Snyk has created this PR to upgrade posthog-js from 1.376.0 to 1.376.4.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-06-24 16:25:36 +02:00
Bolaji Ayodeji cc2a0ad322 Add DPG badge to README 2026-06-22 16:35:41 +02:00
Florent Chehab 3300226bbe 🧱(helm) include app labels and cron job name in cron jobs
Jobs created from cron jobs were lacking some labels that ease
with targetting them notably as part of network policies.
2026-06-22 15:38:26 +02:00
Florent Chehab 8efb11b3ae ⬆️(backend) upgrade cryptography package
The version we were using had a CVE.
2026-06-22 10:59:37 +02:00
Florent Chehab 8cf15cabaf 🔨(summary) change default transcribe model
This changes the default model when transcribing in dev
to openai/whisper-large-v3, which is the model provided
by AlbertAPI.
2026-06-18 18:10:50 +02:00
Florent Chehab 9b7c449ca5 🐛(summary) explicit transcription response format
We should be able to use other transcription services,
those usually relie on response_format="diarized_json"
to produce what we need.

Note that as part of this change, we stop using
openai library for making this call to avoid
casting the result to a payload that doesn't
contain the elements we used to rely on.
(setting this specific format auto cast the
results in openai lib). We keep the old
result class used.
2026-06-18 18:10:50 +02:00
Florent Chehab 5c27aba00f (summary) relaxed WhisperX response model
When going through AlbertAPI, timestamp are not provided at the word level.
This adds default values so that the summary external contract stays the same,
while giving us compatibility wisht AlbertAPI.
2026-06-18 18:10:03 +02:00
Florent Chehab 7256b87511 👷(ci) push images on integration branches
* Usefull for debugging directly on stagin
2026-06-18 18:10:03 +02:00
Florent Chehab 7b7951a0e0 🧱(helm) run clean files command as cronjob
Updates the help chart to be able to run a list of CronJobs.
By defaults it runs the clean_pending_files
and purge_deleted_files commands during the night.
2026-06-18 18:07:43 +02:00
Florent Chehab 5c15140783 (backend) add command to clean pending and deleted files
Since we have added custom backgrounds, they could be
soft deleted but were never really deleted.
This commits introduces a command to actually delete
this files.
In introduces also a command to purge pending upload
.
2026-06-18 18:07:43 +02:00
lebaudantoine 898bc9a0f8 🔖(minor) bump release to 1.21.0 2026-06-15 18:29:52 +02:00
Florent Chehab fd1715bacf ️(frontend) proper aria labels on custom backgrounds
* Set the aria-label to something meaningful,
* Set the delete btn aria label
2026-06-15 17:49:50 +02:00
Florent Chehab c9de7d049f ️(frontend) improve effects accessibility structure
* Set sub-headings to level 3, to be coherent
* Set role to list & list-item on background items list
2026-06-15 17:49:47 +02:00
Florent Chehab 6368b676a6 ️(frontend) set aria hidden on video effects preview
It makes sense to set aria-hidden for this video.
2026-06-15 17:49:47 +02:00
Florent Chehab 65789ef706 ️(frontend) fix blur effects aria label
Was configured to always light.
2026-06-15 17:49:46 +02:00
lebaudantoine c0feb1ee82 🐛(frontend) fix metadata agent collector enabled check
Fix an incorrect check that caused issues in production when
determining whether the metadata agent collector is enabled.
2026-06-15 17:48:50 +02:00
leo 135b99aee7 (summary) add optional satisfaction survey footer
Add a footer to transcription outputs linking to an external satisfaction
survey. The survey URL is built from TRANSCRIPTION_SATISFACTION_FORM_BASE_URL.
When TRANSCRIPTION_SATISFACTION_FORM_BASE_URL is unset or None, the
footer is omitted.
2026-06-15 17:33:24 +02:00
lebaudantoine 040df0e15a 🚸(frontend) mute participants by default when joining a large meeting
When joining a meeting that already has many participants, new
participants are now muted by default.

This is an empirical change, not directly requested by users but
informed by user experience: a lot of people joining large meetings
arrive with their microphone and/or camera open, which can be
painful for the host, who otherwise has to mute everyone or ask
everyone to mute. Many of these participants are inattentive but
still noisy.

If you are joining a room that is already at a certain size, you are
probably not the one expected to speak; presenters typically join
among the very first participants.
2026-06-15 17:02:00 +02:00
lebaudantoine 00e197b216 🚸(frontend) mute join notification sound in larger rooms
In particularly large rooms, with more than 5 or 10 participants,
the entry notification sound can quickly feel spammy.

A configuration to disable it exists, but new users do not discover
it easily. The sound notification is most useful while waiting for
the first few participants to arrive; once a few people are in, the
meeting usually starts and the sound becomes more disruptive than
helpful.

The visual notification remains in place, so users are still aware
when newcomers join. Roll this out and check whether users find the
change helpful.
2026-06-15 17:02:00 +02:00
lebaudantoine eae1a382d5 🩹(frontend) fix options unwrapping when silent login is disabled
The options were not properly unwrapped when silent login was
disabled, leading to incorrect behavior in that code path.
2026-06-14 00:49:42 +02:00
lebaudantoine d4a7cf279c (frontend) allow hiding the login button via a URL parameter
Required by some self-hosters who want to present a link to
participants that are guests only, without access to their SSO. For
example, a meeting between a citizen and a public servant where the
guest is known not to have an account.

Also useful when rendering the join page inside an iframe.
2026-06-14 00:49:42 +02:00
lebaudantoine 70a296eea6 (frontend) allow disabling silent login via a URL parameter
Several clients ran into issues with the silent login, leading to
redirections that were not appropriate for their use case.

Offer a URL parameter to control this behavior and disable silent
login when needed.
2026-06-14 00:49:42 +02:00
lebaudantoine ac85a20271 ️(frontend) lazy-load @libreaudio/la-call via dynamic import
The noise-suppression processor is now imported on demand with a
dynamic import() instead of a top-level static import, so it lands in
its own code-split chunk rather than the main bundle.

Why this is necessary:

* @libreaudio/la-call is fully self-contained. It inlines *everything*
  as JavaScript: two WASM binaries (SIMD and non-SIMD variants), the
  Emscripten glue, the worklet processor source, and the noise model
  plus its weights — the model is compiled into the .wasm, so we ship
  effectively two copies of it.

* The WASM is inlined as numeric array literals (new Uint8Array([...])),
  the least compact representation possible (~2-4 source chars/byte) and
  not meaningfully minifiable. The result is a large module that also
  can't be stream-compiled the way an external .wasm asset would be.

* A static import would pull all of that into the initial bundle,
  inflating critical-path download size and lengthening build time
  (parsing/minifying the big literal) — for a feature that's only used
  when the user actually turns on noise suppression.

* Dynamic import() isolates the whole payload in a separate,
  content-hashed, browser-cached chunk. The cost (chunk fetch + WASM
  instantiation) becomes a one-time hit deferred to first activation,
  and is fully off the page's initial load path.

Notes:

* The library self-bundles its AudioWorklet at runtime from a Blob URL
  and inlines the WASM, so it needs no build-tool asset plumbing
  (no ?worker&url / ?url, no Vite asset config). The dynamic import is
  therefore the only splitting mechanism required.

* Audio processing still runs off the main thread on the AudioWorklet
  path (desktop/most browsers); only the Android ScriptProcessor
  fallback runs on the main thread. Import style does not affect this.

* To hide the first-activation latency, the chunk can be prefetched
  (e.g. import() on idle or <link rel="modulepreload">) so it's warm
  before the user enables suppression.
2026-06-13 13:30:20 +02:00
falkTX 13036f6ab7 (frontend) enhance noise reduction with BBBA audio processing pipeline
Replace the basic RNN noise processor with a more advanced audio
pipeline powered by Big Blue Better Audio (BBBA), a project
supported by the Prototype Fund.

The new WASM-based pipeline introduced by @falkTX and
@trummerschlunk adds:
- voice isolation
- high-pass filtering
- spectral balancing
- multiband compression
- low-pass filtering

This significantly improves overall audio quality and speech
clarity.

More information about the pipeline is available on the
trummerschlunk/BigBlueBetterAudio repo.

Voice isolation still relies on RNNNoise, a widely used deep
learning-based denoising model. Audio quality could be further
improved in the future if DeepFilterNet becomes usable directly
in the browser.

Their code has been released in an NPM package under GPL license.
2026-06-13 13:30:20 +02:00
lebaudantoine 64819b3696 🔖(minor) bump release to 1.20.0 2026-06-12 17:25:56 +02:00
lebaudantoine 53722ad1bc 🩹(frontend) fix CSP regression breaking inline styles and ProConnect
A previous CSP change suggested by CodeRabbit was not properly
tested and broke inline styling as well as the loading of the
ProConnect image.

Adjust the CSP directives to allow these resources again.
2026-06-12 15:54:26 +02:00
lebaudantoine dcfdd35c82 🔖(helm) release chart 0.0.23 2026-06-12 09:38:53 +02:00
snyk-bot 16f465432d fix: upgrade react-i18next from 15.1.1 to 17.0.8
Snyk has created this PR to upgrade react-i18next from 15.1.1 to 17.0.8.

See this package in npm:
react-i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-06-12 09:11:07 +02:00
lebaudantoine 90f95ab2a9 ⬆️(frontend) upgrade libcrypto3 and libssl3 to 3.5.7-r0
Patches CVE-2026-45447 (HIGH), heap use-after-free in OpenSSL
PKCS7_verify(), flagged by Trivy scan of the frontend image.

- libcrypto3: 3.5.6-r0 -> 3.5.7-r0
- libssl3: 3.5.6-r0 -> 3.5.7-r0

Ref: https://avd.aquasec.com/nvd/cve-2026-45447
2026-06-11 16:26:13 +02:00
leo 61f7ad05e9 🐛(frontend) fix noise reduction left-channel-only audio
Fix bug with RNNoise noise reduction which interprets mono input
as left channel with some browsers.
2026-06-11 16:26:13 +02:00
lebaudantoine 69a6dd1463 🩹(frontend) fix missing default-src in CSP configuration
The CSP was missing a default-src directive (flagged by CodeRabbit).
Without it, styles, images, fonts, and media are completely
unrestricted, which undercuts the otherwise strict policy.

Set default-src to 'self' and add an explicit style-src directive
since we rely on inline styles (e.g. #close-msg and the view
toggling use inline style attributes), so style-src needs to allow
'self' and 'unsafe-inline'.
2026-06-11 15:16:52 +02:00
lebaudantoine 6d06aee92d 📝(addon) update the changelog
please refer the previous commits where I enhanced the whole
addin features.
2026-06-11 15:16:52 +02:00
lebaudantoine 54908b9caa 🔧(addon) parametrize the frontend nginx configuration via a volume
Mount the nginx configuration used by the frontend image as a volume
so the default one can be overridden by a custom configuration at
deployment time.

Based on suggestions from @rouja to help parametrize the
configuration at deployment time.
2026-06-11 15:16:52 +02:00
lebaudantoine 08af6e77bb 📌(addon) pin dependencies to their currently installed versions
Improve code quality and reproducibility by pinning project
dependencies to the exact versions currently installed.
2026-06-11 15:16:52 +02:00
lebaudantoine 4525c9c255 💄(addon) align the beta tag with the UI kit styling
Update the beta tag to follow the UI kit styling so it clearly
signals that the plugin is in beta.
2026-06-11 15:16:52 +02:00
lebaudantoine 44d3ed8f2e (addon) add a feedback form link in the footer
Add the possibility to display a feedback form link in the footer to
help collect feedback from the first users of the plugin.

Once the plugin is in production at scale, this will be replaced
with a link to the support page.
2026-06-11 15:16:52 +02:00
lebaudantoine 9522dc72ac 📈(addon) allow appending an Outlook source query param to the URL
Offer the possibility to append a query parameter at the end of the
meeting URL to flag links created from the Outlook plugin, inspired
by Zoom.

The behavior is toggled via an environment variable.
2026-06-11 15:16:52 +02:00
lebaudantoine 09dbb250a7 🚸(addon) switch add button to remove when a link already exists
From the task panel, change the "add" button to "remove" when a
meeting link is already present.

This will be updated to "update" once the external API supports
updating an existing room.
2026-06-11 15:16:52 +02:00
lebaudantoine 321cb99f82 🩹(addon) skip link generation when one already exists in the event
Detect whether a meeting link is already present in the calendar
event item or its body, and prevent generating a new link if one is
found.

Detection is based on the presence of the app URL in the text. This
is imperfect but covers the most naive scenarios.
2026-06-11 15:16:52 +02:00
lebaudantoine 52bad6faaf 🔖(addons) bump plugin version to 0.0.2
Update the plugin version displayed to the user to 0.0.2 and move the
tag from alpha to beta.
2026-06-11 15:16:52 +02:00
lebaudantoine 0065527a5e 🌐(addon) internationalize the addon
Refactor the plugin to support internationalization and ship it in
three languages.

Internationalizing requires updating the manifest, which involves
touching the Helm chart and releasing a new version. Ship a beta
version of the plugin once the i18n work is done to bundle these
changes together.
2026-06-11 15:16:52 +02:00
lebaudantoine 818d20888e 💄(addons) use the app logo for the "create a meeting link" ribbon action
In the ribbon, the "create a meeting link" action was displayed with
the generic add icon, which is fitting in the context of a menu but
not explicit enough on its own.

Following feedback from the social ministries, replace the add icon
with the app logo to make it clear that this button creates a
videoconference link.
2026-06-11 15:16:52 +02:00
lebaudantoine f1fce4431f ♻️(addons) insert meeting link at the cursor position
Instead of appending the meeting link at the end of the email, insert
it where the user's cursor is. This ensures the link is not placed
after the email signature, or below the quoted thread in a reply.
2026-06-11 15:16:52 +02:00
lebaudantoine 491866e584 ♻️(addons) try using the default client font
Refactor the message builder so that rendered messages are displayed
in a nicer way. Based on user feedback from the social ministries.

Go for a hybrid approach: on the desktop client, insert plain text so
it picks up the default font configured by the client theme; on the
web client, insert HTML, as the editor there is rendered using HTML.
2026-06-11 15:16:52 +02:00
lebaudantoine f777f2fdeb 🩹(addon) show fallback message when dialog cannot auto-close
The dialog window does not close itself on Outlook Desktop, which
opens links in a webview that behaves like a different browser.

Calling window.close() fails because, for security reasons, JS is
prevented from closing the window. The browser considers that the JS
script is not the one that opened the dialog.

The dialog also loses its reference to the opener due to the
redirection, so there is no way to message the parent to trigger a
close.

Keeping the dialog reference on the parent side does not help either:
since the opener is lost, the parent cannot call close on the dialog.

After investigation, go with a temporary solution that shows an
explicit message hinting the user to close the dialog manually if it
does not close automatically.
2026-06-11 15:16:52 +02:00
122 changed files with 3184 additions and 2721 deletions
+10 -10
View File
@@ -46,7 +46,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -65,7 +65,7 @@ jobs:
target: backend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -92,7 +92,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -112,7 +112,7 @@ jobs:
target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -139,7 +139,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -159,7 +159,7 @@ jobs:
target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -186,7 +186,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -208,7 +208,7 @@ jobs:
target: production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -235,7 +235,7 @@ jobs:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -257,7 +257,7 @@ jobs:
target: production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+58 -1
View File
@@ -8,6 +8,63 @@ and this project adheres to
## [Unreleased]
## [1.22.0] - 2026-07-03
### Added
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
- 📝(docs) document rebranding the favicon via a volume mount #1443
- ✨(backend) add command to clean pending and deleted files
- 🧱(helm) run clean files command as cronjob
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
### Changed
- ✨(summary) generalized stt api call #1420
- ♻️(env) refactor env variables handling
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
### Fixed
- 🛂(backend) reject user access tokens on the API
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
## [1.21.0] - 2026-06-15
### Added
- ✨(frontend) allow disabling silent login via a URL parameter
- ✨(frontend) allow hiding the login button via a URL parameter
- ✨(summary) add optional satisfaction survey footer
### Changed
- ✨(frontend) enhance noise reduction with BBBA audio processing pipeline
- 🚸(frontend) mute join notification sound in larger rooms
- 🚸(frontend) mute participants by default when joining a large meeting
### Fixed
- 🐛(frontend) fix metadata agent collector enabled check
### Fixed
- ♿️(frontend) improve accessibilty of the Effects panel #1401
## [1.20.0] - 2026-06-12
### Changed
- ♻️(addon) improve Outlook add-on: i18n support, feedback link, smarter link
- ⬆️(frontend) upgrade react-i18next from 15.1.1 to 17.0.8
### Fixed
- 🐛(frontend) fix noise reduction left-channel-only audio
## [1.19.0] - 2026-06-04
### Added
@@ -25,7 +82,7 @@ and this project adheres to
- 🔇(summary) make ffmpeg quiet #1404
- 🔒️(backend) prevent accessing files if they are not ready #1395
- ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
- # ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
## [1.18.0] - 2026-06-03
+5 -1
View File
@@ -75,7 +75,8 @@ create-env-files: \
env.d/development/kc_postgresql \
env.d/development/summary \
env.d/development/kube-secret \
env.d/development/multi_user_transcriber
env.d/development/multi_user_transcriber \
env.d/development/metadata_collector
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -292,6 +293,9 @@ env.d/development/kube-secret:
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
env.d/development/metadata_collector:
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
# -- Internationalization
env.d/development/crowdin:
+59 -13
View File
@@ -12,7 +12,10 @@
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
</a>
</p>
<p align="center">
@@ -28,6 +31,14 @@
## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
> [!TIP]
> New here? Start by introducing yourself in our Matrix channel:
> **https://matrix.to/#/#meet-official:matrix.org**
>
> Were happy to discuss ideas, answer questions, and help to deploy LaSuite Meet.
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
@@ -52,7 +63,7 @@ Were continuously adding new features to enhance your experience, with the la
### 🚀 Major roll out to all French public servants
On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html))
## Table of Contents
@@ -84,22 +95,57 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
#### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access |
|---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| Url | Org | Access |
|---------------------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [Clever Cloud](https://www.clever.cloud/product/visio/) | clever cloud | Openvisio is a sovereign video conferencing solution based on LaSuite Meet offered by [Clever Cloud](https://www.clever.cloud/). |
| [Email.eu](https://email.eu/) | Email.eu | Sovereign business workspace. |
## Contributing
# Contributing
We <3 contributions of any kind, big and small:
We <3 contributions of all kinds **big or small** and were genuinely glad youre here. 🌱
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
### Start by saying hi
**The best first contribution is simply to come say hi.**
Before opening a PR, especially a larger one, or one written with the help of AI, we encourage you to reach out to a maintainer on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org) (@antoine.lebaud:matrix.org).
Getting in touch early helps us align on goals, avoid duplicated or wasted effort, and build a community that stays active, welcoming, and fun to be part of. There are no silly questions here: whether youve shipped hundreds of PRs or youre just getting started, youre welcome.
### AI contributions
AI-assisted contributions are welcome. But code is never the end goal. What matters most is building relationships, sharing knowledge, and growing a sustainable community over time.
If your contribution has been heavily generated with AI, please be transparent about it. This helps maintainers review it with the right context and respects the time they invest in the project.
Using AI does not transfer ownership of the contribution: you should still fully understand the code, the problem it solves, and the reasoning behind the approach you propose. In short, even if AI helped write it, the why should still be yours.
### Contributions beyond code
**Not technical? We need you too.**
Open source is much more than code. Writing documentation, improving onboarding, translating content, answering questions, reporting bugs, or simply helping others feel welcome all make a huge difference.
### Ways to contribute
When youre ready, here are a few ways to get involved:
* 👋 **Say hello** and share your ideas with the community and maintainers on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org)
* 🛠️ **Open a PR** by following our guide to [develop La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)
* 💡 **Suggest an idea** by opening a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md)
* 🐛 **Report a bug** by opening a [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
Thank you for helping build something open, useful, and human. 💙
### Community call
We host a community call on the first Friday of every month to share updates, discuss ideas, and connect with contributors.
Whether youre actively contributing or just curious about the project, youre welcome to join. More details are shared on the [Matrix channel](https://matrix.to/#/#meet-official:matrix.org).
## Philosophy
+11 -18
View File
@@ -93,7 +93,7 @@ services:
networks:
- resource-server
- default
celery-dev:
user: ${DOCKER_USER:-1000}
image: meet:backend-development
@@ -237,29 +237,22 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress:v1.11.0
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
- ./docker/livekit/out:/out
depends_on:
- redis
image: livekit/egress:v1.11.0
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
- ./docker/livekit/out:/out
depends_on:
- redis
metadata-collector-dev:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
environment:
- LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=secret
- AWS_S3_ENDPOINT_URL=minio:9000
- AWS_S3_ACCESS_KEY_ID=meet
- AWS_S3_SECRET_ACCESS_KEY=password
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
- AWS_S3_SECURE_ACCESS=False
env_file:
- env.d/development/metadata_collector
volumes:
- ./src/agents:/app
- /app/.venv
+2
View File
@@ -60,6 +60,8 @@ USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
+4 -1
View File
@@ -48,9 +48,12 @@ server {
set $nonce $request_id;
set $csp "upgrade-insecure-requests; ";
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
+1 -1
View File
@@ -96,7 +96,7 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
-1
View File
@@ -80,7 +80,6 @@ sequenceDiagram
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
+64 -1
View File
@@ -244,6 +244,69 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## Rebranding the favicon
The favicon is bundled into the frontend image and served as a set of static
files from `/usr/share/nginx/html` (`favicon.ico`, `favicon-16x16.png`,
`favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`,
`android-chrome-512x512.png`, `icon.png`). To rebrand without forking and
rebuilding the image, overlay your own icons onto those paths with a volume —
this serves the right icon from the first byte (no rebuild, no flash) and
covers every variant, including the iOS home-screen and Android/PWA icons.
Put your icons in a `ConfigMap` (`binaryData` keeps the PNGs intact)…
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: meet-favicon
binaryData:
# base64 of each replacement icon
favicon.ico: <base64…>
favicon-16x16.png: <base64…>
favicon-32x32.png: <base64…>
apple-touch-icon.png: <base64…>
android-chrome-192x192.png: <base64…>
android-chrome-512x512.png: <base64…>
```
```bash
# e.g. build the ConfigMap straight from a directory of icons
$ kubectl create configmap meet-favicon --from-file=./my-icons/
```
…then mount each file over the bundled one via the chart's
`frontend.extraVolumes` / `frontend.extraVolumeMounts` (the `subPath` mounts
the single file without hiding the rest of `html/`):
```yaml
frontend:
extraVolumes:
- name: favicon
configMap:
name: meet-favicon
extraVolumeMounts:
- name: favicon
mountPath: /usr/share/nginx/html/favicon.ico
subPath: favicon.ico
- name: favicon
mountPath: /usr/share/nginx/html/favicon-16x16.png
subPath: favicon-16x16.png
- name: favicon
mountPath: /usr/share/nginx/html/favicon-32x32.png
subPath: favicon-32x32.png
- name: favicon
mountPath: /usr/share/nginx/html/apple-touch-icon.png
subPath: apple-touch-icon.png
- name: favicon
mountPath: /usr/share/nginx/html/android-chrome-192x192.png
subPath: android-chrome-192x192.png
- name: favicon
mountPath: /usr/share/nginx/html/android-chrome-512x512.png
subPath: android-chrome-512x512.png
```
## All options
These are the environmental options available on meet backend.
@@ -344,7 +407,7 @@ These are the environmental options available on meet backend.
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
+5
View File
@@ -57,6 +57,7 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_INTERNAL_URL=http://livekit:7880
LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
@@ -86,6 +87,9 @@ ROOM_TELEPHONY_ENABLED=True
# Metadata
METADATA_COLLECTOR_ENABLED=True
# Subtitle
ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
@@ -94,3 +98,4 @@ EXTERNAL_API_ENABLED=True
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
@@ -0,0 +1,9 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -2,8 +2,10 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=kyutai
STT_PROVIDER=kyutai # kyutai, deepgram
ENABLE_SILERO_VAD=False
DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
+9 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
@@ -20,5 +20,13 @@ LLM_MODEL="albert-large"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED=true
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP=0.5
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS=true
RESOLVE_SPEAKER_MAX_WORD_DURATION=1
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
# Transcription
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
+32 -13
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.1.0</Version>
<Version>0.0.2.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
@@ -87,9 +87,9 @@
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
@@ -126,9 +126,9 @@
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
@@ -175,15 +175,34 @@
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
+65 -28
View File
@@ -9,33 +9,35 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"core-js": "^3.49.0",
"regenerator-runtime": "^0.14.1"
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"@babel/core": "7.29.0",
"@babel/preset-env": "7.29.0",
"@types/office-js": "1.0.582",
"@types/office-runtime": "1.0.36",
"acorn": "8.16.0",
"babel-loader": "9.2.1",
"copy-webpack-plugin": "14.0.0",
"eslint-plugin-office-addins": "4.0.6",
"file-loader": "6.2.0",
"html-loader": "5.1.0",
"html-webpack-inject-attributes-plugin": "1.0.6",
"html-webpack-plugin": "5.6.6",
"office-addin-cli": "2.0.6",
"office-addin-debugging": "6.0.6",
"office-addin-dev-certs": "2.0.6",
"office-addin-lint": "3.0.6",
"office-addin-manifest": "2.1.2",
"office-addin-prettier-config": "2.0.1",
"os-browserify": "0.3.0",
"process": "0.11.10",
"source-map-loader": "5.0.0",
"webpack": "5.105.4",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.4"
}
},
@@ -2111,9 +2113,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -9363,6 +9363,43 @@
"node": ">=10.18"
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -15213,7 +15250,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+27 -25
View File
@@ -26,33 +26,35 @@
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "^3.49.0",
"regenerator-runtime": "^0.14.1"
"core-js": "3.49.0",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"@babel/core": "7.29.0",
"@babel/preset-env": "7.29.0",
"@types/office-js": "1.0.582",
"@types/office-runtime": "1.0.36",
"acorn": "8.16.0",
"babel-loader": "9.2.1",
"copy-webpack-plugin": "14.0.0",
"eslint-plugin-office-addins": "4.0.6",
"file-loader": "6.2.0",
"html-loader": "5.1.0",
"html-webpack-inject-attributes-plugin": "1.0.6",
"html-webpack-plugin": "5.6.6",
"office-addin-cli": "2.0.6",
"office-addin-debugging": "6.0.6",
"office-addin-dev-certs": "2.0.6",
"office-addin-lint": "3.0.6",
"office-addin-manifest": "2.1.2",
"office-addin-prettier-config": "2.0.1",
"os-browserify": "0.3.0",
"process": "0.11.10",
"source-map-loader": "5.0.0",
"webpack": "5.105.4",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.4"
},
"prettier": "office-addin-prettier-config",
+45 -28
View File
@@ -1,12 +1,18 @@
/* global Office */
const { APP_NAME } = require("../common/index");
const { createRoom, initSession } = require("../common/api");
const { startPolling } = require("../common/polling");
const { saveSession, loadSession } = require("../common/session");
const { openTransitDialog } = require("../common/transitDialog");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { applyAppName } = require("../common/helpers");
const { initI18n, t } = require("../common/i18n");
const { isMeetingAlreadyAdded } = require("../common/meetingDetector");
Office.onReady(async function (info) {
await initI18n()
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
@@ -22,41 +28,52 @@ function notify(message) {
}
function insertMeetingLink(event, session) {
const item = Office.context.mailbox.item;
isMeetingAlreadyAdded(item)
.then((alreadyAdded) => {
if (alreadyAdded) {
notify(t("meeting.already_added", { app_name: APP_NAME }));
event.completed();
return;
}
return _doInsertMeetingLink(event, session);
})
.catch((err) => {
notify(t("meeting.error.details", { message: err.message }));
event.completed();
});
}
function _doInsertMeetingLink(event, session) {
createRoom(session)
.then((data) => {
const { url, message } = buildMeetingMessage(data);
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
const { url, text } = buildMeetingMessage(data, isWeb);
const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => {
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de lecture : ${getResult.error.message}`);
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: setResult.error.message }));
resolve();
return;
}
const newBody = getResult.value + message;
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur d'insertion : ${setResult.error.message}`);
resolve();
return;
}
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify(t("meeting.link_inserted"));
resolve();
return;
}
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify("Lien de réunion inséré !");
resolve();
return;
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: locationResult.error.message }));
} else {
notify(t("meeting.link_inserted"));
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de localisation : ${locationResult.error.message}`);
} else {
notify("Lien de réunion inséré !");
}
resolve();
});
resolve();
});
});
});
@@ -79,11 +96,11 @@ function connect(event) {
});
},
onTimeout: () => {
notify("Connexion expirée, veuillez réessayer.");
notify(t("meeting.error.auth"));
event.completed();
},
onError: (err) => {
notify("Une erreur est survenue, veuillez ré-essayer");
notify(t("meeting.error.retry"));
event.completed();
},
});
@@ -99,7 +116,7 @@ function connect(event) {
});
})
.catch((err) => {
notify(`Erreur : ${err.message}`);
notify(t("meeting.error.details", { message: err.message }));
event.completed();
});
}
+48
View File
@@ -0,0 +1,48 @@
const { APP_NAME } = require("../common");
const i18nextModule = require("i18next");
const i18next = i18nextModule.default || i18nextModule;
const fr = require("../locales/fr/translation.json");
const en = require("../locales/en/translation.json");
const de = require("../locales/de/translation.json");
async function initI18n() {
const lng = typeof Office !== "undefined" ? Office.context.displayLanguage : navigator.language;
await i18next.init({
lng,
fallbackLng: "fr",
interpolation: { escapeValue: false },
resources: {
fr: { translation: fr },
en: { translation: en },
de: { translation: de },
},
});
}
function t(key, vars) {
return i18next.t(key, vars);
}
function translateUI() {
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
el.textContent = t(key, { app_name: APP_NAME });
});
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
const pairs = el.getAttribute("data-i18n-attr").split(",");
pairs.forEach((pair) => {
const [attr, key] = pair.split(":");
el.setAttribute(attr, t(key, { app_name: APP_NAME }));
});
});
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
el.setAttribute("aria-label", t(el.getAttribute("data-i18n-aria")));
});
}
module.exports = { initI18n, t, translateUI };
+4
View File
@@ -1,7 +1,11 @@
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
const ENABLE_SOURCE_TRACKING = window.__APP_CONFIG__?.ENABLE_SOURCE_TRACKING === "true";
const FEEDBACK_FORM = window.__APP_CONFIG__?.FEEDBACK_FORM || null;
module.exports = {
BASE_URL,
APP_NAME,
ENABLE_SOURCE_TRACKING,
FEEDBACK_FORM
};
@@ -0,0 +1,107 @@
const { BASE_URL } = require("./index");
/**
* Returns a promise that resolves to true if a meeting link is already present
*/
function isMeetingAlreadyAdded(item) {
return Promise.all([_checkBody(item), _checkLocation(item)]).then(
([inBody, inLocation]) => inBody || inLocation
);
}
function _checkBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Text, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _checkLocation(item) {
// Location only exists on appointments
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _containsMeetingUrl(text) {
if (!text) return false;
return text.includes(BASE_URL);
}
function removeMeetingLink(item) {
return Promise.all([_removeFromBody(item), _removeFromLocation(item)]);
}
function _removeFromBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Html, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve();
return;
}
const cleaned = _cleanBody(result.value || "");
if (cleaned === null) {
resolve();
return;
}
item.body.setAsync(cleaned, { coercionType: Office.CoercionType.Html }, () => resolve());
});
});
}
function _removeFromLocation(item) {
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve();
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (
result.status === Office.AsyncResultStatus.Succeeded &&
_containsMeetingUrl(result.value)
) {
item.location.setAsync("", () => resolve());
} else {
resolve();
}
});
});
}
const SEPARATOR = /─{10,}/;
/**
* Returns cleaned HTML, or null if no meeting block was found.
*/
function _cleanBody(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const hits = [];
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (SEPARATOR.test(walker.currentNode.nodeValue)) hits.push(walker.currentNode);
}
if (hits.length < 2) return null;
const range = doc.createRange();
range.setStartBefore(hits[0]);
range.setEndAfter(hits[hits.length - 1]);
range.deleteContents();
return doc.documentElement.outerHTML;
}
module.exports = { isMeetingAlreadyAdded, removeMeetingLink };
+43 -16
View File
@@ -1,4 +1,5 @@
const { APP_NAME } = require("./index");
const { APP_NAME, ENABLE_SOURCE_TRACKING } = require("./index");
const { t } = require("./i18n");
function _formatPin(pin) {
if (!pin) return "";
@@ -20,33 +21,59 @@ function _formatPhone(phone) {
return clean;
}
function _appendTrackingParams(url) {
if (!ENABLE_SOURCE_TRACKING) return url;
const u = new URL(url);
u.searchParams.set("from", "outlook-addin");
return u.toString();
}
// todo - escape html / link
function buildMeetingMessage(data) {
function buildMeetingMessage(data, isWeb) {
if (!data?.url) {
throw new Error("buildMeetingMessage: missing url in data");
}
const url = data.url;
const url = _appendTrackingParams(data.url);
const phone = _formatPhone(data.telephony?.phone_number);
const pin = _formatPin(data.telephony?.pin_code);
const telephonyBlock =
phone && pin
? `
let textLines = "";
let phoneLines = [];
Ou appelez (audio uniquement)
(FR) ${phone}
Code : ${pin}`
: "";
const join = t("meeting_message.join", { app_name: APP_NAME });
const phoneOnly = t("meeting_message.phone_only");
const phoneFr = t("meeting_message.phone_fr", { phone });
const pinCode = t("meeting_message.pin_code", { pin });
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion ${APP_NAME}
if (isWeb) {
phoneLines = phone && pin ? [`<br><br>${phoneOnly}`, `<br>${phoneFr}`, `<br>${pinCode}`] : [];
<a href="${url}">${url}</a>${telephonyBlock}
────────────────────────────────────────</pre>`;
textLines = [
"<br><br>────────────────────────────────────────",
`<br>${join}`,
`<br><br><a href="${url}" target="_blank">${url}</a>`,
...phoneLines,
"<br>────────────────────────────────────────<br>",
];
return { url, message };
} else {
phoneLines = phone && pin ? [`\n\n${phoneOnly}`, `\n${phoneFr}`, `\n${pinCode}`] : [];
textLines = [
"\n\n────────────────────────────────────────",
`\n${join}`,
`\n\n${url}`,
...phoneLines,
"\n────────────────────────────────────────\n",
];
}
const text = textLines.join("");
return { url, text };
}
module.exports = { buildMeetingMessage };
@@ -0,0 +1,40 @@
{
"app": {
"sideload": "Laden Sie das Add-In.",
"loading": "Wird geladen..."
},
"unauth": {
"intro": "Fügen Sie Ihren Outlook-Terminen ganz einfach einen {{app_name}}-Besprechungslink hinzu.",
"proconnect_btn": "Aanmelden met ProConnect",
"proconnect_link": "Wat is ProConnect?",
"proconnect_link_title": "Wat is ProConnect? - nieuw venster"
},
"success": {
"close_window": "Falls sich dieses Fenster nicht automatisch schließt, schließen Sie es bitte manuell."
},
"auth": {
"disconnect": "Abmelden"
},
"meeting": {
"already_added": "Es wurde bereits ein {{app_name}}-Meeting hinzugefügt.",
"link_inserted": "Besprechungslink erfolgreich eingefügt",
"generating": "Wird erstellt...",
"add_meeting": "{{app_name}}-Besprechung hinzufügen",
"remove_meeting": "{{app_name}}-Besprechung entfernen",
"removing": "Wird entfernt...",
"error": {
"auth": "Ihre Sitzung ist abgelaufen. Bitte versuchen Sie es erneut.",
"retry": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"details": "Fehler: {{message}}"
}
},
"meeting_message": {
"join": "An der {{app_name}}-Besprechung teilnehmen",
"phone_only": "Oder per Telefon teilnehmen (nur Audio)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Teilen Sie uns Ihr Feedback mit"
}
}
@@ -0,0 +1,40 @@
{
"app": {
"sideload": "Please load the add-in.",
"loading": "Loading..."
},
"unauth": {
"intro": "Easily add a {{app_name}} meeting link to your Outlook events.",
"proconnect_btn": "Sign in with ProConnect",
"proconnect_link": "What is ProConnect?",
"proconnect_link_title": "What is ProConnect? - new window"
},
"success": {
"close_window": "If this window does not close automatically, please close it manually."
},
"auth": {
"disconnect": "Sign out"
},
"meeting": {
"already_added": "A {{app_name}} meeting has already been added.",
"link_inserted": "Meeting link inserted successfully",
"generating": "Generating...",
"add_meeting": "Add a {{app_name}} meeting",
"remove_meeting": "Remove the {{app_name}} meeting",
"removing": "Removing...",
"error": {
"auth": "Your session has expired. Please try again.",
"retry": "An error occurred. Please try again.",
"details": "Error: {{message}}"
}
},
"meeting_message": {
"join": "Join the {{app_name}} meeting",
"phone_only": "Or call in (audio only)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Share your feedback"
}
}
@@ -0,0 +1,40 @@
{
"app": {
"sideload": "Veuillez charger le complément.",
"loading": "Chargement..."
},
"unauth": {
"intro": "Ajoutez facilement un lien de réunion {{app_name}} à vos événements Outlook.",
"proconnect_btn": "S'identifier avec ProConnect",
"proconnect_link": "Qu'est-ce que ProConnect ?",
"proconnect_link_title": "Qu'est-ce que ProConnect ? - nouvelle fenêtre"
},
"success": {
"close_window": "Si cette fenêtre ne se ferme pas toute seule, veuillez la fermer manuellement."
},
"auth": {
"disconnect": "Se déconnecter"
},
"meeting": {
"already_added": "Une réunion {{app_name}} a déjà été ajoutée.",
"link_inserted": "Lien de réunion inséré avec succès",
"generating": "Génération...",
"add_meeting": "Ajouter une réunion {{app_name}}",
"remove_meeting": "Supprimer la réunion {{app_name}}",
"removing": "Suppression en cours...",
"error": {
"auth": "Connexion expirée, veuillez réessayer.",
"retry": "Une erreur est survenue, veuillez ré-essayer",
"details": "Erreur : {{message}}"
}
},
"meeting_message": {
"join": "Rejoindre la réunion {{app_name}}",
"phone_only": "Ou appelez (audio uniquement)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Partagez-nous vos retours"
}
}
+3 -2
View File
@@ -9,10 +9,10 @@
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div class="spinner-container"
role="progressbar"
aria-label="Chargement..."
data-i18n-aria="app.loading"
>
<svg class="spinner-svg"
viewBox="0 0 28 28"
@@ -40,5 +40,6 @@
</svg>
</span>
</div>
<p id="close-msg" style="display: none; text-align: center; font-size: 13px; color: #666; margin-top: 16px;" data-i18n="success.close_window"></p>
</body>
</html>
+29 -14
View File
@@ -1,20 +1,35 @@
const { applyAppName } = require("../common/helpers");
const { exchangeSession } = require("../common/api");
const { consume } = require("../common/transitToken");
const { initI18n, translateUI } = require("../common/i18n");
applyAppName();
(async () => {
await initI18n();
const transitToken = consume();
applyAppName();
translateUI();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
window.close();
});
}
const transitToken = consume();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.then(() => {
document.querySelector(".spinner-container").style.display = "none";
document.querySelector("#close-msg").style.display = "block";
})
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
// this window wasn't opened by this script (it was opened externally),
// so it blocks window.close() for security reasons. The "#close-msg"
// shown above is the fallback for that case.g
window.close();
});
}
})();
+48 -4
View File
@@ -115,15 +115,39 @@ button {
background-color: #f5f5f5;
}
/* ── Danger button (remove meeting) ── */
#btn-remove {
background-color: #CA3632; /* error.400 */
color: #FFFFFF;
border: none;
}
#btn-remove:hover {
background-color: #EE6A66; /* error.600 */
}
#btn-remove:active {
background-color: #F28D8A; /* error.700 */
color: #F6AFAD; /* error.200 */
}
#btn-remove:disabled {
background-color: #F6AFAD; /* error.800 */
color: #FAD2D1; /* error.900 */
cursor: not-allowed;
}
/* ── Version ── */
#version-tag {
position: fixed;
bottom: 8px;
left: 8px;
right: 8px;
display: inline-flex;
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
@@ -131,9 +155,29 @@ button {
pointer-events: none;
}
#feedback-link {
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
text-decoration: underline;
pointer-events: all; /* override parent's pointer-events: none */
cursor: pointer;
}
#feedback-link:hover {
color: #374151;
}
#footer-right {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.version-badge {
background: #fef3c7;
color: #92400e;
background: #EEF1F4;
color: #2845C1;
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
+25 -16
View File
@@ -10,47 +10,56 @@
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text">Chargement...</p>
<p class="intro-text" data-i18n="app.loading"></p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
<span data-i18n="unauth.intro"></span>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
<span class="proconnect-sr-only" data-i18n="unauth.proconnect_btn"></span>
</button>
<p>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
<a href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
data-i18n-attr="title:unauth.proconnect_link_title"
data-i18n="unauth.proconnect_link"
></a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
<button id="btn-disconnect">Se déconnecter</button>
<!-- shown when no meeting is present -->
<button id="btn-generate" data-i18n="meeting.add_meeting"></button>
<!-- shown when a meeting is already present -->
<button id="btn-remove" style="display:none;" data-i18n="meeting.remove_meeting"></button>
<button id="btn-disconnect" data-i18n="auth.disconnect"></button>
</div>
</div>
</div>
<footer id="version-tag">
<span class="version-badge">alpha</span>
<span class="version-number">0.0.1</span>
<a id="feedback-link"
style="display:none;"
target="_blank"
rel="noopener noreferrer"
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-badge">beta</span>
<span class="version-number">0.0.2</span>
</div>
</footer>
</body>
</html>
+113 -42
View File
@@ -1,22 +1,77 @@
const { APP_NAME } = require("../common");
/* global Office */
const { APP_NAME, FEEDBACK_FORM } = require("../common");
const { applyAppName } = require("../common/helpers");
const { initSession, createRoom } = require("../common/api");
const { startPolling } = require("../common/polling");
const { openTransitDialog } = require("../common/transitDialog");
const { loadSession, saveSession, clearSession } = require("../common/session");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { initI18n, t, translateUI } = require("../common/i18n");
const { isMeetingAlreadyAdded, removeMeetingLink } = require("../common/meetingDetector");
// ── Views ────────────────────────────────────────────────────
// todo - support loading view while polling
// todo - support error view
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
if (name === "auth") {
_refreshMeetingButtonState();
}
}
// ── Button state ─────────────────────────────────────────────
function _showAddButton() {
document.getElementById("btn-generate").style.display = "block";
document.getElementById("btn-remove").style.display = "none";
}
function _showRemoveButton() {
document.getElementById("btn-generate").style.display = "none";
document.getElementById("btn-remove").style.display = "block";
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = t("meeting.generating");
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = t("meeting.add_meeting", { app_name: APP_NAME });
}
function _setRemoveLoading() {
const btn = document.getElementById("btn-remove");
btn.disabled = true;
btn.textContent = t("meeting.removing");
}
function _setRemoveIdle() {
const btn = document.getElementById("btn-remove");
btn.disabled = false;
btn.textContent = t("meeting.remove_meeting", { app_name: APP_NAME });
}
function _refreshMeetingButtonState() {
const item = Office.context.mailbox.item;
if (!item) return;
isMeetingAlreadyAdded(item).then((alreadyAdded) => {
if (alreadyAdded) {
_showRemoveButton();
} else {
_showAddButton();
}
});
}
// ── Auth ─────────────────────────────────────────────────────
function connect() {
initSession()
.then((data) => {
@@ -47,22 +102,11 @@ function disconnect() {
clearSession().finally(() => showView("unauth"));
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
}
// ── Meeting ──────────────────────────────────────────────────
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
console.error("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
@@ -71,36 +115,28 @@ function generateMeetingLink() {
createRoom(session)
.then((data) => {
const { url, message } = buildMeetingMessage(data);
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
const { url, text } = buildMeetingMessage(data, isWeb);
const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => {
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(getResult.error);
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
}
);
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
});
});
})
.then(() => {
_showRemoveButton();
})
.catch((err) => {
console.error(err);
})
@@ -109,7 +145,41 @@ function generateMeetingLink() {
});
}
Office.onReady((info) => {
function removeMeetingLinkFromItem() {
const session = loadSession();
if (!session?.access_token) {
showView("unauth");
return;
}
_setRemoveLoading();
const item = Office.context.mailbox.item;
removeMeetingLink(item)
.then(() => {
_showAddButton();
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setRemoveIdle();
});
}
// ── Init ─────────────────────────────────────────────────────
Office.onReady(async (info) => {
await initI18n();
translateUI();
if (FEEDBACK_FORM) {
const link = document.getElementById("feedback-link");
link.href = FEEDBACK_FORM;
link.style.display = "inline";
}
if (info.host === Office.HostType.Outlook) {
applyAppName();
document.getElementById("sideload-msg").style.display = "none";
@@ -117,10 +187,11 @@ Office.onReady((info) => {
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
document.getElementById("btn-remove").onclick = removeMeetingLinkFromItem;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
showView("auth");
showView("auth"); // this already calls _refreshMeetingButtonState internally
} else {
showView("unauth");
}
+2 -2
View File
@@ -10,11 +10,11 @@
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="sideload-msg" data-i18n="app.sideload"></div>
<div
class="spinner-container"
role="progressbar"
aria-label="Chargement..."
data-i18n-aria="app.loading"
>
<svg
class="spinner-svg"
+6 -1
View File
@@ -2,6 +2,7 @@ const { applyAppName } = require("../common/helpers");
const { URLS } = require("../common/urls");
const { save } = require("../common/transitToken");
const { DIALOG_SIGNALS } = require("../common/transitDialog");
const { initI18n, translateUI } = require("../common/i18n");
// Initiate the authentication flow, then return to the success page
function getAuthenticateUrl() {
@@ -10,7 +11,11 @@ function getAuthenticateUrl() {
return url.toString();
}
Office.onReady(function (info) {
Office.onReady(async function (info) {
await initI18n();
translateUI();
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.19.0"
version = "1.22.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.5.13",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.19.0"
version = "1.22.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+59
View File
@@ -0,0 +1,59 @@
"""
Pluggable analytics.
Usage anywhere in the codebase:
from core import analytics
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
The concrete backend is resolved lazily from Django settings, so swapping
PostHog for anything else is a configuration change, not a code change.
"""
from functools import lru_cache
from typing import Any
from django.conf import settings
from django.utils.module_loading import import_string
from .base import AnalyticsBackend, NoOpAnalytics
from .events import AnalyticsEvent
__all__ = [
"get_analytics",
"identify",
"capture",
"AnalyticsBackend",
"AnalyticsEvent",
]
@lru_cache(maxsize=1)
def get_analytics() -> AnalyticsBackend:
"""Instantiate the configured backend once per process."""
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
backend_class = import_string(dotted_path)
return backend_class(**options)
# Convenience module-level shortcuts
analytics_instance = get_analytics()
def identify(user, properties: dict[str, Any] | None = None) -> None:
"""Associate traits with an identified user."""
analytics_instance.identify(user, properties)
def capture(
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
) -> None:
"""Record an event performed by an identified user."""
analytics_instance.capture(user, event, properties)
+50
View File
@@ -0,0 +1,50 @@
"""Analytics backend protocol and default no-op implementation."""
from typing import Any, Protocol
from ..models import User
from .events import AnalyticsEvent
class AnalyticsBackend(Protocol):
"""
Interface every analytics backend must implement.
Backends are instantiated once (singleton) with the kwargs declared in
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
"""
def __init__(self, **kwargs: Any) -> None: ...
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
def capture(
self,
user: User,
event: AnalyticsEvent,
properties: dict[str, Any] | None = None,
) -> None:
"""Record an event performed by an identified user."""
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
class NoOpAnalytics:
"""Default backend: silently discards everything."""
def __init__(self, **kwargs: Any) -> None:
"""No-op: accepts and ignores any backend settings kwargs."""
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
def capture(self, user, event, properties=None) -> None:
"""No-op: discards captured events."""
def shutdown(self) -> None:
"""No-op: nothing to flush."""
+10
View File
@@ -0,0 +1,10 @@
"""Catalog of all analytics events emitted by the backend."""
from enum import StrEnum
class AnalyticsEvent(StrEnum):
"""All trackable events. Values are the wire names sent to the provider."""
# Rooms
ROOM_CREATED = "room_created"
+74
View File
@@ -0,0 +1,74 @@
"""PostHog implementation of the analytics backend protocol."""
import logging
from typing import Any
from posthog import Posthog
from ..models import User
from .events import AnalyticsEvent
logger = logging.getLogger(__name__)
class PostHogAnalytics:
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
def __init__(
self,
*,
api_key: str,
host: str = "https://eu.i.posthog.com",
**kwargs: Any,
) -> None:
# The SDK batches and sends in a background thread by default,
# so calls below never block the request/response cycle.
self._client = Posthog(
project_api_key=api_key,
host=host,
**kwargs,
)
@staticmethod
def _distinct_id(user: User) -> str | None:
"""Return the PostHog distinct_id for a user, or None if anonymous."""
if user is None or not getattr(user, "is_authenticated", False):
return None
return str(user.pk)
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return
try:
self._client.set(
distinct_id=distinct_id,
properties=properties or {},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("PostHog identify failed")
def capture(
self,
user: User,
event: AnalyticsEvent,
properties: dict[str, Any] | None = None,
) -> None:
"""Record an event performed by an identified user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return
try:
self._client.capture(
distinct_id=distinct_id,
event=str(event),
properties=properties or {},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("PostHog capture failed for event %s", event)
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
self._client.shutdown()
+25 -18
View File
@@ -35,7 +35,7 @@ from rest_framework import (
)
from rest_framework.settings import api_settings
from core import enums, models, utils
from core import analytics, enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -46,12 +46,15 @@ from core.recording.event.exceptions import (
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsService,
RecordingNotSavableError,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -305,6 +308,16 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"from_callback": bool(self.request.data.get("callback_id")),
},
)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
@@ -972,24 +985,15 @@ class RecordingViewSet(
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if not recording.is_savable():
# Save recording
recording_events_service = RecordingEventsService()
try:
recording_events_service.handle_complete(recording)
except RecordingNotSavableError:
raise drf_exceptions.PermissionDenied(
f"Recording with ID {recording_id} cannot be saved because it is either,"
" in an error state or has already been saved."
)
# Attempt to notify external services about the recording
# This is a non-blocking operation - failures are logged but don't interrupt the flow
notification_succeeded = notification_service.notify_external_services(
recording
)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
) from None
return drf_response.Response(
{"message": "Event processed."},
@@ -1187,7 +1191,10 @@ class FileViewSet(
serializer.save(creator=self.request.user)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
"""Override to implement a soft delete instead of dumping the record in database.
Files are actually purged by commands that should run periodically.
"""
instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from rest_framework.authentication import SessionAuthentication
from core.models import User
from core.services.marketing import (
@@ -96,3 +97,17 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"Multiple user accounts share a common email."
) from e
return None
class SessionAuthenticationWith401(SessionAuthentication):
"""
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
header so unauthenticated requests get a 401 instead of a 403.
The scheme is deliberately NOT 'Basic' that would trigger the browser's
native login popup. 'Session' is ignored by the browser's auth UI but is
still truthy, so DRF keeps the status at 401.
"""
def authenticate_header(self, request):
return "Session"
+19 -3
View File
@@ -19,7 +19,7 @@ from rest_framework import (
status as drf_status,
)
from core import api, models
from core import analytics, api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
@@ -194,10 +194,26 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
room.id,
self.request.user.id,
getattr(self.request.auth, "client_id", "unknown"),
client_id,
auth_method,
)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
},
)
@@ -0,0 +1,47 @@
"""Clean stale pending files that were never fully uploaded."""
from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from core.models import File, FileUploadStateChoices
from core.tasks.file import process_file_deletion
class Command(BaseCommand):
"""Remove pending files older than a given threshold."""
help = "Delete pending files that have been stuck for too long"
def add_arguments(self, parser):
parser.add_argument(
"--hours",
type=int,
default=24,
help="Age threshold in hours (default: 24)",
)
def handle(self, *args, **options):
hours = options["hours"]
if hours < 0:
raise CommandError("Hours must be greater than 0")
threshold = timezone.now() - timedelta(hours=hours)
files = File.objects.filter(
upload_state=FileUploadStateChoices.PENDING,
created_at__lt=threshold,
hard_deleted_at__isnull=True,
)
count = 0
for file in files.iterator():
# This check shouldn't happen, but just in case we do it to avoid an error
if not file.deleted_at:
file.soft_delete()
file.hard_delete()
process_file_deletion(file.id)
count += 1
self.stdout.write(f"Cleaned {count} stale pending file(s).")
@@ -0,0 +1,40 @@
"""Purge deleted files."""
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from core.models import File
from core.tasks.file import process_file_deletion
class Command(BaseCommand):
"""
Purge deleted files (object storage and database object):
- files marked as hard deleted in database
- files marked as soft deleted and for which the trashbin retention period has expired
"""
help = "Purge deleted files"
def handle(self, *args, **options):
"""Browse purgeable files and queue them through the file deletion task."""
is_hard_deleted = Q(hard_deleted_at__isnull=False)
is_purgeable = Q(
deleted_at__lte=timezone.now()
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
)
count = 0
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
if file.hard_deleted_at is None:
file.hard_delete()
process_file_deletion.delay(file.id)
count += 1
self.stdout.write(f"Purged {count} deleted file(s).")
+5 -1
View File
@@ -6,6 +6,7 @@ import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Optional, Protocol
from urllib.parse import quote
from django.conf import settings
from django.utils.module_loading import import_string
@@ -58,7 +59,7 @@ class EventParser(Protocol):
def parse(self, data: Dict) -> StorageEvent:
"""Extract storage event data from raw dictionary input."""
def validate(self, data: StorageEvent) -> None:
def validate(self, data: StorageEvent) -> str:
"""Verify storage event data meets all requirements."""
def get_recording_id(self, data: Dict) -> str:
@@ -164,6 +165,9 @@ class S3Parser(BaseS3Parser):
if not filepath:
raise ParsingEventDataError("Missing object key name")
filetype, _ = mimetypes.guess_type(filepath)
# Normalize raw S3-compatible object keys without re-encoding
# already encoded AWS S3 notification keys.
filepath = quote(filepath, safe="%+")
return StorageEvent(
filepath=filepath,
filetype=filetype,
@@ -8,6 +8,7 @@ from livekit import api
from core import models, utils
from core.models import Recording
from core.recording.event.notification import notification_service
logger = getLogger(__name__)
@@ -16,6 +17,10 @@ class RecordingEventsError(Exception):
"""Recording event handling fails."""
class RecordingNotSavableError(Exception):
"""Recording cannot be saved because it is either in an error state or has already been saved"""
class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
@@ -73,3 +78,23 @@ class RecordingEventsService:
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@staticmethod
def handle_complete(recording: Recording):
"""Notify external services and save recording."""
if not recording.is_savable():
raise RecordingNotSavableError
# Attempt to notify external services about the recording
# This is a non-blocking operation - failures are logged but don't interrupt the flow
notification_succeeded = notification_service.notify_external_services(
recording
)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
+30 -7
View File
@@ -19,6 +19,7 @@ from core.recording.services.metadata_collector import (
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from .lobby import LobbyService
@@ -88,6 +89,13 @@ class LiveKitEventsService:
def __init__(self):
"""Initialize with required services."""
self._webhook_handlers = {
"egress_updated": self._handle_egress_updated,
"egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started,
"room_finished": self._handle_room_finished,
}
token_verifier = api.TokenVerifier(
settings.LIVEKIT_CONFIGURATION["api_key"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
@@ -135,14 +143,11 @@ class LiveKitEventsService:
f"Unknown webhook type: {data.event}"
) from e
handler_name = f"_handle_{webhook_type.value}"
handler = getattr(self, handler_name, None)
# Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value)
if not handler or not callable(handler):
return
# pylint: disable=not-callable
handler(data)
if handler is not None:
handler(data)
def _handle_egress_updated(self, data):
"""Handle 'egress_updated' event."""
@@ -195,6 +200,24 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
# Fallback for completion when no MinIO/S3 webhooks are configured
if (
not settings.RECORDING_STORAGE_EVENT_ENABLE
) and data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED,
]:
try:
self.recording_events.handle_complete(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on egress complete "
"(already saved or in an error state); ignoring.",
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -0,0 +1,263 @@
"""
Unit tests for PostHogAnalytics.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
import pytest
from core.analytics.events import AnalyticsEvent
from core.analytics.posthog import PostHogAnalytics
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# ==============================
# __init__
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
"""Should forward api_key and host to the Posthog SDK constructor."""
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
mock_posthog_cls.assert_called_once_with(
project_api_key="my-key",
host="https://custom.i.posthog.com",
)
@patch("core.analytics.posthog.Posthog")
def test_init_defaults_to_eu_host(mock_posthog_cls):
"""Should default host to the EU PostHog cloud when not specified."""
PostHogAnalytics(api_key="my-key")
_, kwargs = mock_posthog_cls.call_args
assert kwargs["host"] == "https://eu.i.posthog.com"
@patch("core.analytics.posthog.Posthog")
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
_, kwargs = mock_posthog_cls.call_args
assert kwargs["debug"] is True
assert kwargs["disabled"] is False
# ==============================
# _distinct_id
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
"""Should return None when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(None) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
"""Should return None when user.is_authenticated is falsy."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(AnonymousUser()) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
"""Should return None when the user object has no is_authenticated attribute at all."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(object()) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
"""Should return str(user.pk) for an authenticated user."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
assert backend._distinct_id(user) == str(user.pk)
# ==============================
# identify
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
"""Should not call the SDK when the user is anonymous."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.identify(AnonymousUser(), {"email": "a@example.com"})
mock_posthog_cls.return_value.set.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_identify_noop_for_none_user(mock_posthog_cls):
"""Should not call the SDK when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.identify(None, {"email": "a@example.com"})
mock_posthog_cls.return_value.set.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
"""Should call capture with event=$identify and properties wrapped in $set."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.identify(user, {"email": "a@example.com", "name": "A"})
mock_posthog_cls.return_value.set.assert_called_once_with(
distinct_id=str(user.pk),
properties={"email": "a@example.com", "name": "A"},
)
@patch("core.analytics.posthog.Posthog")
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
"""Should send an empty $set payload when properties is None."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.identify(user, None)
mock_posthog_cls.return_value.set.assert_called_once_with(
distinct_id=str(user.pk),
properties={},
)
@patch("core.analytics.posthog.Posthog")
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
"""Should log and not raise when the SDK call fails."""
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
# Must not propagate.
backend.identify(user, {"email": "a@example.com"})
# ==============================
# capture
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
"""Should not call the SDK when the user is anonymous."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
mock_posthog_cls.return_value.capture.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_capture_noop_for_none_user(mock_posthog_cls):
"""Should not call the SDK when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
mock_posthog_cls.return_value.capture.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
"""Should call capture with the distinct_id, event name, and properties."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
mock_posthog_cls.return_value.capture.assert_called_once_with(
distinct_id=str(user.pk),
event="room_created",
properties={"room_id": "room-1"},
)
@patch("core.analytics.posthog.Posthog")
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
_, kwargs = mock_posthog_cls.return_value.capture.call_args
assert kwargs["event"] == "room_created"
assert isinstance(
kwargs["event"], str
) # not AnalyticsEvent, not StrEnum subclass leaking through
@patch("core.analytics.posthog.Posthog")
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
"""Should send an empty properties dict when properties is None."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
mock_posthog_cls.return_value.capture.assert_called_once_with(
distinct_id=str(user.pk),
event="room_created",
properties={},
)
@patch("core.analytics.posthog.Posthog")
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
"""Should log and not raise when the SDK call fails."""
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
# Must not propagate.
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
@patch("core.analytics.posthog.Posthog")
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
"""Should log which event failed, to aid debugging without crashing the caller."""
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with caplog.at_level("ERROR"):
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
assert any("PostHog capture failed" in record.message for record in caplog.records)
# ==============================
# shutdown
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_shutdown_flushes_the_client(mock_posthog_cls):
"""Should delegate to the SDK's shutdown to flush pending events."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.shutdown()
mock_posthog_cls.return_value.shutdown.assert_called_once()
@@ -0,0 +1,90 @@
"""Tests for the clean_pending_files management command."""
from datetime import timedelta
from django.core.files.storage import default_storage
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_clean_pending_files_no_stale_files():
"""Nothing happens when there are no stale pending files."""
call_command("clean_pending_files")
def test_clean_pending_files_recent_pending_not_deleted():
"""Recent pending files (within threshold) should not be deleted."""
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.file_key)
def test_clean_pending_files_old_pending_deleted():
"""Pending files older than the threshold should be deleted."""
old_date = timezone.now() - timedelta(hours=49)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
assert default_storage.exists(file.file_key)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
call_command("clean_pending_files")
assert not models.File.objects.filter(pk=file.pk).exists()
assert not default_storage.exists(file.file_key)
def test_clean_pending_files_old_non_pending_not_deleted():
"""Old files that are not pending should not be deleted."""
old_date = timezone.now() - timedelta(hours=49)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert file.hard_deleted_at is None
def test_clean_pending_files_custom_hours():
"""The --hours argument controls the age threshold."""
old_date = timezone.now() - timedelta(hours=10)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
# Default 24h threshold -> file not deleted
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.file_key)
# 8h threshold -> file deleted
call_command("clean_pending_files", "--hours=8")
assert not models.File.objects.filter(pk=file.pk).exists()
assert not default_storage.exists(file.file_key)
@@ -0,0 +1,85 @@
"""Tests for the purge_deleted_files management command."""
from datetime import timedelta
from io import StringIO
from random import randint
from unittest.mock import patch
from django.core.files.storage import default_storage
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
from core.tasks.file import process_file_deletion
pytestmark = pytest.mark.django_db
def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
"""Nothing happens when there are no purgeable files."""
with django_assert_num_queries(1):
call_command("purge_deleted_files")
@pytest.mark.django_db(transaction=True)
def test_purge_deleted_files_success(settings):
"""
Queue deletion for:
- hard-deleted files
- soft-deleted files past retention period + grace period.
"""
out = StringIO()
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
now = timezone.now()
purge_now = now - timedelta(days=grace)
not_deleted_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
with patch("django.utils.timezone.now", return_value=now):
not_purgeable_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
not_purgeable_file.soft_delete()
with patch("django.utils.timezone.now", return_value=purge_now):
purgeable_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
purgeable_file.soft_delete()
hard_deleted_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
hard_deleted_file.soft_delete()
hard_deleted_file.hard_delete()
with patch(
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
side_effect=process_file_deletion,
) as mock_delay:
call_command("purge_deleted_files", stdout=out)
assert "Purged 2 deleted file(s)." in out.getvalue()
assert mock_delay.call_count == 2
called_ids = {call.args[0] for call in mock_delay.call_args_list}
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
assert models.File.objects.filter(id=not_deleted_file.id).exists()
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
assert not models.File.objects.filter(id=purgeable_file.id).exists()
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
assert default_storage.exists(not_deleted_file.file_key)
assert default_storage.exists(not_purgeable_file.file_key)
assert not default_storage.exists(purgeable_file.file_key)
assert not default_storage.exists(hard_deleted_file.file_key)
@@ -360,6 +360,75 @@ def test_s3_parse_unrecognized_extension(s3_parser):
s3_parser.parse(event_with_unknown_ext)
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
"""Test S3 parser keeps already encoded object keys compatible."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_accepts_unencoded_filepath(settings):
"""Test S3 parser accepts raw object keys with slash separators."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings/{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
"""Test S3 parser preserves plus signs in already encoded object keys."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
"""Test successful extraction of recording ID from S3 event."""
recording_id = s3_parser.get_recording_id(valid_s3_event)
@@ -224,3 +224,44 @@ def test_save_recording_success(recording_settings, mock_get_parser, client, sta
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@pytest.mark.parametrize("notification_succeeded", [True, False])
def test_save_recording_notifies_external_services(
mock_notify_external_services,
recording_settings,
mock_get_parser,
client,
notification_succeeded,
):
"""External services should be notified when a recording is saved."""
recording = RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
mock_notify_external_services.return_value = notification_succeeded
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == (
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else RecordingStatusChoices.SAVED
)
@@ -91,7 +91,9 @@ def test_handle_egress_ended_success(
)
recording.refresh_from_db()
assert recording.status == "stopped"
# NB: notify_external_services will return False, so status is "saved"
assert recording.status == "saved"
@pytest.mark.parametrize(
@@ -155,7 +157,7 @@ def test_handle_egress_updated_non_handled(
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording when metadata's update fails."""
"""Should successfully stop and save recording when metadata's update fails."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
@@ -170,7 +172,9 @@ def test_handle_egress_ended_metadata_update_fails(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
# NB: notify_external_services will return False, so status is "saved"
assert recording.status == "saved"
@mock.patch("core.utils.notify_participants")
@@ -326,6 +330,143 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
mock_collector.stop.assert_not_called()
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
)
@pytest.mark.parametrize(
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
recording_status,
egress_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == recording_status
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@pytest.mark.parametrize(
"egress_status, expected_status",
[
(EgressStatus.EGRESS_COMPLETE, "active"),
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata,
mock_notify,
mock_notify_external_services,
egress_status,
expected_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""When storage event webhooks are enabled, egress_ended must not finalize the
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
the recording, EGRESS_COMPLETE leaves it active.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = True
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"egress_status",
[
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings
):
"""Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "active"
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings
):
"""Should handle non-savable recordings idempotently without raising.
'egress_ended' may be redelivered (e.g. for an already-saved recording);
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status=status)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == status
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
+18 -3
View File
@@ -182,7 +182,9 @@ class Base(Configuration):
environ_name="FILE_UPLOAD_ENABLED",
environ_prefix=None,
)
FILE_PURGE_GRACE_DAYS = values.PositiveIntegerValue(
default=7, environ_name="FILE_PURGE_GRACE_DAYS", environ_prefix=None
)
FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
)
@@ -321,8 +323,7 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
"core.authentication.backends.SessionAuthenticationWith401",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
@@ -411,6 +412,12 @@ class Base(Configuration):
"transcription_destination": values.Value(
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
),
"max_participants_for_sound": values.PositiveIntegerValue(
5, environ_name="FRONTEND_MAX_PARTICIPANTS_FOR_SOUND", environ_prefix=None
),
"auto_mute_on_join_threshold": values.PositiveIntegerValue(
50, environ_name="FRONTEND_AUTO_MUTE_ON_JOIN_THRESHOLD", environ_prefix=None
),
}
# Mail
@@ -754,6 +761,14 @@ class Base(Configuration):
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
)
# Analytics
ANALYTICS_BACKEND = values.Value(
None, environ_name="ANALYTICS_BACKEND", environ_prefix=None
)
ANALYTICS_BACKEND_SETTINGS = values.DictValue(
{}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False, # When enabled, new users are automatically added to mailing list.
+2 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.19.0"
version = "1.22.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -50,6 +50,7 @@ dependencies = [
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"posthog==7.16.1",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.13.0",
+80 -48
View File
@@ -161,6 +161,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "billiard"
version = "4.2.4"
@@ -503,55 +512,52 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
@@ -572,6 +578,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "dj-database-url"
version = "3.1.2"
@@ -1069,14 +1084,14 @@ wheels = [
[[package]]
name = "joserfc"
version = "1.6.4"
version = "1.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" },
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
]
[[package]]
@@ -1187,7 +1202,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.19.0"
version = "1.22.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1219,6 +1234,7 @@ dependencies = [
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pyjwt" },
@@ -1282,6 +1298,7 @@ requires-dist = [
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.31" },
{ name = "posthog", specifier = "==7.16.1" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", specifier = "==2.13.0" },
@@ -1531,6 +1548,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "posthog"
version = "7.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b4/4f/a954175c862a3565d02c3f627874d85f18313472a0c4b08f45d84aaf3315/posthog-7.16.1.tar.gz", hash = "sha256:3619d3c619ad01f36c6d465e084950882417c63021eb3cfacacb23f900ec52d4", size = 226343, upload-time = "2026-05-27T18:46:20.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" },
]
[[package]]
name = "pprintpp"
version = "0.4.0"
+2
View File
@@ -45,6 +45,8 @@ USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
+83 -377
View File
@@ -1,20 +1,21 @@
{
"name": "meet",
"version": "1.19.0",
"version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.19.0",
"version": "1.22.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@pandacss/preset-panda": "1.11.1",
"@pandacss/preset-panda": "1.11.3",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
@@ -29,11 +30,11 @@
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.0",
"posthog-js": "1.376.0",
"posthog-js": "1.382.0",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"react-i18next": "17.0.8",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -338,9 +339,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1446,6 +1447,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@libreaudio/la-call": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@libreaudio/la-call/-/la-call-0.1.4.tgz",
"integrity": "sha512-yaqlYGpvZbfpr1h2p1jSGOKPUy7NsfpoJN9gXvHmJu5MB8nN/qfx6LQ2a0hbua3H8rargFKROOv4VZ5WuTKNsA==",
"license": "GPL-3.0+"
},
"node_modules/@livekit/components-core": {
"version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
@@ -1654,252 +1661,6 @@
"node": ">= 8"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-exporter-base": "0.208.0",
"@opentelemetry/otlp-transformer": "0.208.0",
"@opentelemetry/sdk-logs": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-transformer": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/sdk-logs": "0.208.0",
"@opentelemetry/sdk-metrics": "2.2.0",
"@opentelemetry/sdk-trace-base": "2.2.0",
"protobufjs": "^7.3.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz",
"integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.5.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz",
"integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.39.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
@@ -1928,6 +1689,16 @@
"typescript": "6.0.2"
}
},
"node_modules/@pandacss/config/node_modules/@pandacss/preset-panda": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.1.tgz",
"integrity": "sha512-+0ZV4d5TZIqbXLh4xRIptuOd/VOc7YncOfeHZn9uWzGKi04uob9I/8mQ/hgSSRWCKmaLpRzsBDuijr2ODD59iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pandacss/types": "1.11.1"
}
},
"node_modules/@pandacss/config/node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
@@ -2022,6 +1793,16 @@
"pandacss": "bin.js"
}
},
"node_modules/@pandacss/dev/node_modules/@pandacss/preset-panda": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.1.tgz",
"integrity": "sha512-+0ZV4d5TZIqbXLh4xRIptuOd/VOc7YncOfeHZn9uWzGKi04uob9I/8mQ/hgSSRWCKmaLpRzsBDuijr2ODD59iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pandacss/types": "1.11.1"
}
},
"node_modules/@pandacss/extractor": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@pandacss/extractor/-/extractor-1.11.1.tgz",
@@ -2349,12 +2130,24 @@
}
},
"node_modules/@pandacss/preset-panda": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.1.tgz",
"integrity": "sha512-+0ZV4d5TZIqbXLh4xRIptuOd/VOc7YncOfeHZn9uWzGKi04uob9I/8mQ/hgSSRWCKmaLpRzsBDuijr2ODD59iA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
"integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
"license": "MIT",
"dependencies": {
"@pandacss/types": "1.11.1"
"@pandacss/types": "1.11.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
"integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/@pandacss/reporter": {
@@ -2411,87 +2204,24 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.1.tgz",
"integrity": "sha512-VkigbeCwlDyyMBlQeRwOMLshSRjNB/2HqvfZvaVXT4+5miAYUAKeQDR88UqUL2aqPWkltTxMVEwBnqSQdC8+jw==",
"dev": true,
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.29.9",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.9.tgz",
"integrity": "sha512-DjvuIyBZ2Z/gBhtZlITlM2D8PlnMsHSQ1D78dbUYoVsgGguvanpJTobZObjLlFkybyvfZFYkpoJkFNI/2Pw4IQ==",
"version": "1.30.10",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.30.10.tgz",
"integrity": "sha512-R7Z5jDB3ugwfSujMmRd5osPPR6L6BqfcaSNcYOekzRMZ4Jklq74p05xByP09EnUvKXb5czI+RQVCITTWRWuFXw==",
"license": "MIT",
"dependencies": {
"@posthog/types": "1.376.0"
"@posthog/types": "1.382.0"
}
},
"node_modules/@posthog/types": {
"version": "1.376.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.376.0.tgz",
"integrity": "sha512-gbFfxCuZDs/D4QZMwdE+smD1jsuqgGpS6yKGHZZ19foxMy8RYHsU1E47iG1b88n/uN02fAabLibVwuxLtq8juw==",
"version": "1.382.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.382.0.tgz",
"integrity": "sha512-iK4OcSgvtmS9FZ9EUpvwlRZmHCLXaZ3+6dbRjkE7q9LL0zHLewxJH84H6uGvCw8aGzxs5rIliZqPHgimTcQEaw==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@react-aria/autocomplete": {
"version": "3.0.0-rc.4",
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.4.tgz",
@@ -4282,7 +4012,10 @@
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -9964,12 +9697,6 @@
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/look-it-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz",
@@ -10889,18 +10616,13 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.376.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.376.0.tgz",
"integrity": "sha512-YGfQ6gSmqmEh287PHjXRDJ9zML3Su1UIt1+xjRy7Yk6yW43Sc7sFK3CpCkLchCGhIA4x6VaqK+LaqB+7+MCo7A==",
"version": "1.382.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.382.0.tgz",
"integrity": "sha512-lXwVlNdPLkhDft48ZgLQ5Jf4RsuQVwz7Hr7luD4DAG+4wDGA+k9eE2no36r3Z1w4uK0EmIf9lmav6+4RsmP7nA==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@posthog/core": "1.29.9",
"@posthog/types": "1.376.0",
"@posthog/core": "1.30.10",
"@posthog/types": "1.382.0",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
@@ -10981,30 +10703,6 @@
"react-is": "^16.13.1"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11235,17 +10933,19 @@
}
},
"node_modules/react-i18next": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.1.1.tgz",
"integrity": "sha512-R/Vg9wIli2P3FfeI8o1eNJUJue5LWpFsQePCHdQDmX0Co3zkr6kdT8gAseb/yGeWbNz1Txc4bKDQuZYsC0kQfw==",
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -11253,6 +10953,9 @@
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
@@ -12797,7 +12500,10 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/universalify": {
"version": "2.0.1",
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.19.0",
"version": "1.22.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -17,11 +17,12 @@
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@libreaudio/la-call": "0.1.4",
"@fontsource/opendyslexic": "5.2.5",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@pandacss/preset-panda": "1.11.1",
"@pandacss/preset-panda": "1.11.3",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
@@ -36,11 +37,11 @@
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.0",
"posthog-js": "1.376.0",
"posthog-js": "1.382.0",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"react-i18next": "17.0.8",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
+3
View File
@@ -9,6 +9,7 @@ export interface ApiConfig {
analytics?: {
id: string
host: string
flags_api_host?: string
}
support?: {
id: string
@@ -55,6 +56,8 @@ export interface ApiConfig {
default_sources: Source[]
}
transcription_destination?: string
max_participants_for_sound: number
auto_mute_on_join_threshold: number
}
const fetchConfig = (): Promise<ApiConfig> => {
@@ -28,10 +28,16 @@ export const terminateAnalyticsSession = async () => {
export type useAnalyticsProps = {
id?: string
host?: string
flags_api_host?: string
isDisabled?: boolean
}
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
export const useAnalytics = ({
id,
host,
flags_api_host,
isDisabled,
}: useAnalyticsProps) => {
const [location] = useLocation()
const { user } = useUser()
@@ -39,9 +45,13 @@ export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
if (!id || !host || isDisabled) return
getPosthog().then((ph) => {
if (ph.__loaded) return
ph.init(id, { api_host: host, person_profiles: 'always' })
ph.init(id, {
api_host: host,
flags_api_host: flags_api_host,
person_profiles: 'always',
})
})
}, [id, host, isDisabled])
}, [id, host, flags_api_host, isDisabled])
useEffect(() => {
if (!user) return
+19 -3
View File
@@ -5,6 +5,16 @@ import { type ApiUser } from './ApiUser'
import { useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
const SILENT_LOGIN_PARAM = 'silentLogin'
const isSilentLoginDisabledByUrl = () => {
if (typeof window === 'undefined') return false
const value = new URLSearchParams(window.location.search).get(
SILENT_LOGIN_PARAM
)
return value === 'false'
}
/**
* returns info about currently logged-in user
*
@@ -17,16 +27,22 @@ export const useUser = (
) => {
const { data, isLoading: isConfigLoading } = useConfig()
const disabledByUrl = useMemo(() => isSilentLoginDisabledByUrl(), [])
const options = useMemo(() => {
if (isConfigLoading) return
if (data?.is_silent_login_enabled !== true) {
const silentDisabled =
data?.is_silent_login_enabled !== true || disabledByUrl
if (silentDisabled) {
return {
...opts,
...opts.fetchUserOptions,
attemptSilent: false,
}
}
return opts.fetchUserOptions
}, [data, opts, isConfigLoading])
}, [data, opts, isConfigLoading, disabledByUrl])
const query = useQuery({
queryKey: [keys.user],
@@ -44,7 +44,8 @@ export function PaginationControl({
if (totalPageCount <= 1) return null
return (
<div
<nav
aria-label={t('label')}
className={css({
position: 'absolute',
bottom: '1rem',
@@ -74,7 +75,7 @@ export function PaginationControl({
<RiArrowLeftSLine />
</Button>
<span
aria-live="polite"
role="status"
className={css({
padding: '0.25rem 0.5rem',
})}
@@ -93,6 +94,6 @@ export function PaginationControl({
>
<RiArrowRightSLine />
</Button>
</div>
</nav>
)
}
@@ -14,9 +14,11 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
import { NotificationProvider } from './NotificationProvider'
import { useConfig } from '@/api/useConfig'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { data } = useConfig()
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce()
@@ -135,12 +137,21 @@ export const MainNotificationToast = () => {
}
}, [room, handleEmoji])
const triggerNotificationSoundIfRoomIsSmall = useCallback(
(type: NotificationType) => {
if (!data) return
if (room.numParticipants >= data.max_participants_for_sound) return
triggerNotificationSound(type)
},
[room, data, triggerNotificationSound]
)
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
if (isMobileBrowser()) {
return
}
triggerNotificationSound(NotificationType.ParticipantJoined)
triggerNotificationSoundIfRoomIsSmall(NotificationType.ParticipantJoined)
toastQueue.add(
{
participant,
@@ -155,7 +166,7 @@ export const MainNotificationToast = () => {
return () => {
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
}
}, [room, triggerNotificationSound])
}, [room, triggerNotificationSoundIfRoomIsSmall])
useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => {
@@ -1,4 +1,5 @@
export enum NotificationType {
AutoMuteLargeRoom = 'autoMuteLargeRoom',
ParticipantJoined = 'participantJoined',
HandRaised = 'handRaised',
ParticipantMuted = 'participantMuted',
@@ -0,0 +1,54 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { VStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { StyledToastContainer } from './StyledToastContainer'
import { useRoomContext } from '@livekit/components-react'
export function ToastAutoMuteLargeRoom({
state,
...props
}: Readonly<ToastProps>) {
const room = useRoomContext()
const { t } = useTranslation('notifications', {
keyPrefix: 'autoMuteLargeRoom',
})
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const toast = props.toast
const handleDismiss = async () => {
room.localParticipant
.setMicrophoneEnabled(true)
.finally(() => state.close(toast.key))
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<VStack
justify="start"
alignItems="self-start"
{...contentProps}
maxWidth="370px"
gap="0.75rem"
padding={14}
>
<p>{t('auto')}</p>
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={() => handleDismiss()}
>
{t('dismiss')}
</Button>
</VStack>
</StyledToastContainer>
)
}
@@ -13,6 +13,7 @@ import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -45,6 +46,11 @@ const renderToast = (
case NotificationType.LowerHand:
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
case NotificationType.AutoMuteLargeRoom:
return (
<ToastAutoMuteLargeRoom key={toast.key} toast={toast} state={state} />
)
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.TranscriptionLimitReached:
@@ -5,6 +5,15 @@ import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording'
export const notifyAutoMutedOnJoin = () => {
toastQueue.add(
{
type: NotificationType.AutoMuteLargeRoom,
},
{ timeout: NotificationDuration.ALERT }
)
}
export const showLowerHandToast = (
participant: Participant,
onClose: () => void
@@ -68,7 +68,7 @@ export const PipView = () => {
</ConnectionStateWrapper>
<PipStage />
<ReactionsToolbar adjustedCentering={false} />
<PipControlBar showScreenShare={false} />
<PipControlBar showScreenShare={true} />
<PipFloatingReactions />
<NotificationProvider bottom={30} />
</Container>
@@ -0,0 +1,96 @@
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
interface PipPaginationProps {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
export const PipPagination = ({
totalPageCount,
currentPage,
nextPage,
prevPage,
}: PipPaginationProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
if (totalPageCount <= 1) return null
return (
<Nav aria-label={t('label')}>
<ArrowButton
type="button"
onClick={prevPage}
disabled={currentPage === 1}
aria-label={t('previous')}
>
<RiArrowLeftSLine size={18} />
</ArrowButton>
<Counter role="status">
{t('count', { currentPage, totalPageCount })}
</Counter>
<ArrowButton
type="button"
onClick={nextPage}
disabled={currentPage === totalPageCount}
aria-label={t('next')}
>
<RiArrowRightSLine size={18} />
</ArrowButton>
</Nav>
)
}
const Nav = styled('nav', {
base: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
marginTop: '1rem',
flexShrink: 0,
},
})
const ArrowButton = styled('button', {
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1.75rem',
height: '1.75rem',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
color: 'white',
backgroundColor: 'primaryDark.100',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover:not(:disabled)': {
backgroundColor: 'primaryDark.75',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'white',
outlineOffset: '2px',
},
'&:disabled': {
opacity: 0.3,
cursor: 'default',
},
},
})
const Counter = styled('span', {
base: {
fontSize: '0.75rem',
color: 'white',
opacity: 0.8,
whiteSpace: 'nowrap',
padding: '0 0.25rem',
minWidth: '3rem',
textAlign: 'center',
},
})
@@ -1,9 +1,12 @@
import { useMemo } from 'react'
import { useTracks } from '@livekit/components-react'
import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
import {
isTrackReference,
TrackReferenceOrPlaceholder,
@@ -38,21 +41,38 @@ export const PipStage = () => {
[tracks]
)
// Grid mode order: screen share leads, then cameras (already ordered by
// active speaker via the `ActiveSpeakersChanged` update above).
const gridTracks = useMemo(
() =>
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
[screenShareTrack, cameraTracks]
)
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
// page visually stable (active/recent speakers stay put) via its internal
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
if (tracks.length === 0) return null
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (tracks.length > 2) {
// Grid mode: 3+ tracks. Screen share goes first so it leads the grid.
const gridTracks = screenShareTrack
? [screenShareTrack, ...cameraTracks]
: cameraTracks
if (gridTracks.length > 2) {
return (
<StageFrame>
<PipGridLayout tracks={gridTracks} />
</StageFrame>
<StageWrapper>
<StageFrame>
<PipGridLayout tracks={pagination.tracks} />
</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper>
)
}
@@ -75,3 +95,12 @@ export const PipStage = () => {
</StageFrame>
)
}
const StageWrapper = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
},
})
@@ -1,12 +1,19 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { useLocalParticipant } from '@livekit/components-react'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { localParticipant } = useLocalParticipant()
return (
<Container role="region" aria-label={t('stage')} {...{ inert: '' }}>
<Container
role="region"
aria-label={t('stage')}
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
>
{children}
</Container>
)
@@ -15,6 +22,7 @@ export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const Container = styled('div', {
base: {
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
marginLeft: '0.5rem',
@@ -1,3 +1,10 @@
/**
* Maximum number of tiles rendered per PiP page. Beyond this the grid
* paginates so large meetings stay glanceable (active speaker priority)
* instead of subscribing to and rendering every camera in a small window.
*/
export const MAX_PIP_TILES = 5
export type PipTilePlacement = {
gridColumn: string
gridRow: number
@@ -6,5 +6,5 @@ export const useIsMetadataCollectorEnabled = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.metadataCollector)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return (featureEnabled && isAnalyticsEnabled) || true
return !isAnalyticsEnabled || Boolean(featureEnabled)
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
@@ -33,6 +33,9 @@ import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
export const Conference = ({
roomId,
@@ -57,6 +60,8 @@ export const Conference = ({
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const {
mutateAsync: createRoom,
status: createStatus,
@@ -173,6 +178,8 @@ export const Conference = ({
const isMobile = useIsMobile()
const hasAutoMutedRef = useRef(false)
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
@@ -228,6 +235,19 @@ export const Conference = ({
onError={(e) => {
posthog.captureException(e)
}}
onConnected={async () => {
if (!apiConfig) return
if (
userPreferencesSnap.is_auto_mute_large_room_enabled &&
!hasAutoMutedRef.current &&
userConfig.audioEnabled &&
room.numParticipants > apiConfig.auto_mute_on_join_threshold
) {
hasAutoMutedRef.current = true
await room.localParticipant.setMicrophoneEnabled(false)
notifyAutoMutedOnJoin()
}
}}
onDisconnected={(e) => {
const metadata = {
room_id: roomId,
@@ -216,6 +216,14 @@ export const Join = ({
try {
const track = await createLocalAudioTrack({
deviceId: { exact: audioDeviceId },
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
// Audio quality optimized for voice
sampleRate: 48000, // High quality sample rate
channelCount: 1, // Mono for voice calls (saves bandwidth)
sampleSize: 16, // 16-bit audio
})
setDynamicAudioTrack(track)
} catch (error) {
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useMemo, useRef } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
@@ -61,9 +61,19 @@ export const FullScreenShareWarning = ({
await localParticipant.setScreenShareEnabled(false, {}, {})
}
const handleDismissWarning = () => {
const handleDismissWarning = useCallback(() => {
screenSharePreferenceStore.enabled = false
}
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
handleDismissWarning()
}
},
[handleDismissWarning]
)
if (!shouldShowWarning) return null
@@ -98,6 +108,7 @@ export const FullScreenShareWarning = ({
})}
>
<Text
role="alert"
style={{
color: 'white',
flexBasis: '55%',
@@ -117,11 +128,14 @@ export const FullScreenShareWarning = ({
})}
>
<Button
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
variant="tertiary"
size="sm"
style={{
height: 'fit-content',
}}
onKeyDown={handleKeyDown}
onPress={async () => {
await handleStopScreenShare()
}}
@@ -134,6 +148,7 @@ export const FullScreenShareWarning = ({
style={{
height: 'fit-content',
}}
onKeyDown={handleKeyDown}
onPress={() => handleDismissWarning()}
>
{t('ignore')}
@@ -400,6 +400,8 @@ export const EffectsConfiguration = ({
config: ProcessorConfig
isSelected: boolean
tooltip: string
ariaLabel: string
ariaDeleteLabel: string
file: ApiFileItem
}[]
}>(() => {
@@ -426,7 +428,9 @@ export const EffectsConfiguration = ({
const id = deriveIdFromProcessorConfig(config)
return {
id,
tooltip: t(`blur.light.${selectedId === id ? 'clear' : 'apply'}`),
tooltip: t(
`blur.${item.key}.${selectedId === id ? 'clear' : 'apply'}`
),
radius: item.radius,
isSelected: selectedId === id,
Icon: item.icon,
@@ -443,9 +447,11 @@ export const EffectsConfiguration = ({
}
const id = deriveIdFromProcessorConfig(config)
const isSelected = selectedId === id
const prefix = isSelected ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.presets.descriptions.${index}`)
const ariaLabel = `${t(`virtual.presets.${prefix}`)} ${backgroundName}`
const ariaLabelPrefix = t(
isSelected ? `virtual.selectedLabel` : `virtual.apply`
)
const ariaLabel = `${ariaLabelPrefix} ${backgroundName}`
return {
tooltip: backgroundName,
@@ -467,13 +473,22 @@ export const EffectsConfiguration = ({
}
const id = deriveIdFromProcessorConfig(config)
const isSelected = selectedId === id
const ariaLabel = t(
isSelected
? `virtual.personal.selectedLabel`
: `virtual.personal.apply`
)
const ariaDeleteLabel = t('virtual.personal.deleteLabel')
return {
tooltip: file.title,
id,
config,
isSelected: selectedId === id,
isSelected,
file,
ariaLabel,
ariaDeleteLabel,
}
}),
}
@@ -552,6 +567,7 @@ export const EffectsConfiguration = ({
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
borderRadius: '8px',
}}
aria-hidden={true}
/>
) : (
<div
@@ -664,7 +680,7 @@ export const EffectsConfiguration = ({
})}
>
<H
lvl={2}
lvl={3}
style={{
marginBottom: '0.4rem',
}}
@@ -680,6 +696,7 @@ export const EffectsConfiguration = ({
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
role="list"
>
{createFileMutation.isPending &&
fileBeingUploadedObjectUrlRef.current && (
@@ -721,48 +738,54 @@ export const EffectsConfiguration = ({
className={
'hoverGroup ' + css({ position: 'relative' })
}
role="listitem"
>
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.tooltip}
isDisabled={processorOptions.isDisabled}
onChange={getHandleSelectChangeFile(option.file)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.file.url!})`,
}}
data-attr={`toggle-virtual-${option.file.id}`}
/>
</VisualOnlyTooltip>
<Button
className={
'hoverGroupChild ' +
css({
position: 'absolute',
top: '-8px',
right: '-8px',
transition: 'opacity 0.2s ease-in-out',
})
}
size={'xs'}
variant={'tertiary'}
onClick={() => {
if (option.isSelected) {
// we remove the current effect
toggleEffect(option.config)
<div role="group" aria-label={option.file.title}>
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.ariaLabel}
isDisabled={processorOptions.isDisabled}
onChange={getHandleSelectChangeFile(
option.file
)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.file.url!})`,
}}
data-attr={`toggle-virtual-${option.file.id}`}
/>
</VisualOnlyTooltip>
<Button
className={
'hoverGroupChild ' +
css({
position: 'absolute',
top: '-8px',
right: '-8px',
transition: 'opacity 0.2s ease-in-out',
})
}
deleteFileMutation.mutate({
fileId: option.file.id,
})
}}
isDisabled={deleteFileMutation.isPending}
>
<RiDeleteBinLine size={16} />
</Button>
aria-label={option.ariaDeleteLabel}
size={'xs'}
variant={'tertiary'}
onClick={() => {
if (option.isSelected) {
// we remove the current effect
toggleEffect(option.config)
}
deleteFileMutation.mutate({
fileId: option.file.id,
})
}}
isDisabled={deleteFileMutation.isPending}
>
<RiDeleteBinLine size={16} />
</Button>
</div>
</div>
)
)}
@@ -775,9 +798,13 @@ export const EffectsConfiguration = ({
>
<ToggleButton
variant="bigSquare"
aria-label={
uploadNotPossibleSnap.imageBackgroundConfig.label
}
aria-label={`${t(
deriveIdFromProcessorConfig(
uploadNotPossibleSnap.imageBackgroundConfig
) === selectedId
? `virtual.selectedLabel`
: `virtual.apply`
)} ${uploadNotPossibleSnap.imageBackgroundConfig.label}`}
isDisabled={
processorOptions.isDisabled ||
createFileMutation.isPending
@@ -857,7 +884,7 @@ export const EffectsConfiguration = ({
})}
>
<H
lvl={2}
lvl={3}
style={{
marginBottom: '0.4rem',
}}
@@ -872,24 +899,27 @@ export const EffectsConfiguration = ({
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
role="list"
>
{processorOptions.virtualBackgrounds.map((option) => (
<VisualOnlyTooltip key={option.id} tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.ariaLabel}
isDisabled={processorOptions.isDisabled}
onChange={() => toggleEffect(option.config)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.thumbnailPath})`,
}}
data-attr={`toggle-virtual-preset-${option.index}`}
/>
</VisualOnlyTooltip>
<div role="listitem" key={option.id}>
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.ariaLabel}
isDisabled={processorOptions.isDisabled}
onChange={() => toggleEffect(option.config)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.thumbnailPath})`,
}}
data-attr={`toggle-virtual-preset-${option.index}`}
/>
</VisualOnlyTooltip>
</div>
))}
</div>
</div>
@@ -1,14 +1,30 @@
import type { Track, TrackProcessor, ProcessorOptions } from 'livekit-client'
import { NoiseSuppressorWorklet_Name } from '@timephy/rnnoise-wasm'
// This is an example how to get the script path using Vite, may be different when using other build tools
// NOTE: `?worker&url` is important (`worker` to generate a working script, `url` to get its url to load it)
import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet?worker&url'
// Use Jitsi's approach: maintain a global AudioContext variable
// and suspend/resume it as needed to manage audio state
let audioContext: AudioContext
/**
* Lazily load the WASM processor factory.
*
* '@libreaudio/la-call' pulls in a WebAssembly payload, so we defer importing
* it until a processor is actually initialized rather than at module load.
* The import promise is cached so repeated init() calls reuse the same module.
*/
type CreateWasmProcessor =
(typeof import('@libreaudio/la-call'))['createWasmProcessor']
let wasmProcessorPromise: Promise<CreateWasmProcessor> | undefined
function loadWasmProcessor(): Promise<CreateWasmProcessor> {
if (!wasmProcessorPromise) {
wasmProcessorPromise = import('@libreaudio/la-call').then(
(mod) => mod.createWasmProcessor
)
}
return wasmProcessorPromise
}
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
name: string
}
@@ -20,7 +36,7 @@ export class RnnNoiseProcessor implements AudioProcessorInterface {
private source?: MediaStreamTrack
private sourceNode?: MediaStreamAudioSourceNode
private destinationNode?: MediaStreamAudioDestinationNode
private noiseSuppressionNode?: AudioWorkletNode
private noiseSuppressionNode?: AudioNode
async init(opts: ProcessorOptions<Track.Kind.Audio>) {
if (!opts.track) {
@@ -35,16 +51,17 @@ export class RnnNoiseProcessor implements AudioProcessorInterface {
await audioContext.resume()
}
await audioContext.audioWorklet.addModule(NoiseSuppressorWorklet)
this.sourceNode = audioContext.createMediaStreamSource(
new MediaStream([this.source])
)
this.noiseSuppressionNode = new AudioWorkletNode(
audioContext,
NoiseSuppressorWorklet_Name
)
const createWasmProcessor = await loadWasmProcessor()
this.noiseSuppressionNode = await createWasmProcessor(audioContext, {
intensity: 90,
})
if (!this.noiseSuppressionNode) {
throw new Error('Failed to create Wasm processor')
}
this.destinationNode = audioContext.createMediaStreamDestination()
@@ -39,6 +39,19 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('preferences.autoMuteLargeRoom.label')}
description={t('preferences.autoMuteLargeRoom.description')}
isSelected={userPreferencesSnap.is_auto_mute_large_room_enabled}
onChange={(value) =>
(userPreferencesStore.is_auto_mute_large_room_enabled = value)
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</TabPanel>
)
}
+16 -1
View File
@@ -14,6 +14,7 @@ import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint'
import { logout } from '@/features/auth/utils/logout'
import { useMemo } from 'react'
const Logo = () => (
<img
@@ -84,6 +85,16 @@ const LoginHint = () => {
)
}
const HIDE_LOGIN_PARAM = 'hideLogin'
const isLoginButtonHidden = () => {
if (typeof window === 'undefined') return false
const value = new URLSearchParams(window.location.search).get(
HIDE_LOGIN_PARAM
)
return value === 'true'
}
export const Header = () => {
const { t } = useTranslation()
const isHome = useMatchesRoute('home')
@@ -92,6 +103,9 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn } = useUser()
const loginButtonDisabledByUrl = useMemo(() => isLoginButtonHidden(), [])
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
@@ -152,7 +166,8 @@ export const Header = () => {
!isHome &&
!isLegalTerms &&
!isAccessibility &&
!isTermsOfService && (
!isTermsOfService &&
!loginButtonDisabledByUrl && (
<>
<LoginButton proConnectHint={false} />
<LoginHint />
@@ -14,6 +14,10 @@
"auto": "Du hast begonnen zu sprechen deine Hand wird daher automatisch gesenkt.",
"dismiss": "Hand oben lassen"
},
"autoMuteLargeRoom": {
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
"dismiss": "Stummschaltung aufheben"
},
"reaction": {
"description": "{{name}} hat mit {{emoji}} reagiert"
},
+8 -4
View File
@@ -97,6 +97,7 @@
}
},
"pagination": {
"label": "Teilnehmerseiten",
"count": "{{currentPage}} von {{totalPageCount}}",
"next": "nächste Seite",
"previous": "vorherige Seite"
@@ -290,11 +291,14 @@
"apply": "Ersetze deinen Hintergrund:",
"personal": {
"title": "Meine Hintergründe",
"selectedLabel": "Hintergrund abwählen (er ist aktuell angewendet).",
"apply": "Als Hintergrund verwenden.",
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
"uploadInProgress": "Datei wird hochgeladen…",
"deleteLabel": "Benutzerdefinierten Hintergrund löschen.",
"errors": {
"close": "Schließen",
"file_too_large": {
@@ -429,10 +433,10 @@
}
},
"premium": {
"heading": "Premium-Funktion",
"heading": "Advanced-Funktion",
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Premium-Zugang öffnen öffnet in neuem Tab",
"linkAriaLabel": "Dokumentation zum Advanced-Zugang öffnen öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
@@ -482,10 +486,10 @@
}
},
"premium": {
"heading": "Premium-Funktion",
"heading": "Advanced-Funktion",
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Premium-Zugang öffnen öffnet in neuem Tab",
"linkAriaLabel": "Dokumentation zum Advanced-Zugang öffnen öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
@@ -12,6 +12,10 @@
"idleDisconnectModal": {
"label": "Meetings ohne Teilnehmende verlassen",
"description": "Meetings nach einigen Minuten automatisch verlassen, wenn keine andere Person beitritt"
},
"autoMuteLargeRoom": {
"label": "Mikrofon automatisch stummschalten",
"description": "Dein Mikrofon wird automatisch stummgeschaltet, wenn du an einer großen Besprechung teilnimmst."
}
},
"audio": {
@@ -14,6 +14,10 @@
"auto": "It seems you have started speaking, so your hand will be lowered.",
"dismiss": "Keep hand raised"
},
"autoMuteLargeRoom": {
"auto": "You have joined a large meeting. Your microphone has been automatically muted.",
"dismiss": "Unmute"
},
"reaction": {
"description": "{{name}} reacted with {{emoji}}"
},
+8 -4
View File
@@ -97,6 +97,7 @@
}
},
"pagination": {
"label": "Participants pages",
"count": "{{currentPage}} of {{totalPageCount}}",
"next": "next page",
"previous": "previous page"
@@ -290,11 +291,14 @@
"apply": "Replace your background:",
"personal": {
"title": "My backgrounds",
"selectedLabel": "Unselect background (it's currently applied).",
"apply": "Use as background.",
"selectFileTooltip": "Select an image file to use as a personal background",
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
"uploadLimitReached": "You cannot upload more personal backgrounds.",
"uploadInProgress": "Image is being uploaded…",
"deleteLabel": "Delete background.",
"errors": {
"close": "Close",
"file_too_large": {
@@ -428,10 +432,10 @@
}
},
"premium": {
"heading": "Premium feature",
"heading": "Advanced feature",
"body": "This feature is not available to you. Please contact support for more information.",
"linkMore": "Open documentation",
"linkAriaLabel": "Open documentation about premium access - opens in new window",
"linkAriaLabel": "Open documentation about advanced access - opens in new window",
"dividerLabel": "OR",
"login": {
"heading": "You are not logged in!",
@@ -481,10 +485,10 @@
}
},
"premium": {
"heading": "Premium feature",
"heading": "Advanced feature",
"body": "This feature is not available to you. Please contact support for more information.",
"linkMore": "Open documentation",
"linkAriaLabel": "Open documentation about premium access - opens in new window",
"linkAriaLabel": "Open documentation about advanced access - opens in new window",
"dividerLabel": "OR",
"login": {
"heading": "You are not logged in!",
+6 -2
View File
@@ -12,6 +12,10 @@
"idleDisconnectModal": {
"label": "Leave calls with no participants",
"description": "Automatically leaves a call after a few minutes if no other participant joins"
},
"autoMuteLargeRoom": {
"label": "Automatically mute my microphone",
"description": "Automatically mute your microphone when joining a large meeting."
}
},
"audio": {
@@ -21,8 +25,8 @@
"disabled": "Microphone disabled"
},
"noiseReduction": {
"label": "Noise reduction",
"heading": "Noise reduction",
"label": "Advanced voice optimization",
"heading": "Audio filters",
"ariaLabel": {
"enable": "Enable noise reduction",
"disable": "Disable noise reduction"
@@ -14,6 +14,10 @@
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
"dismiss": "Laisser la main levée"
},
"autoMuteLargeRoom": {
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
"dismiss": "Rétablir le micro"
},
"reaction": {
"description": "{{name}} a reagi avec {{emoji}}"
},
+8 -4
View File
@@ -97,6 +97,7 @@
}
},
"pagination": {
"label": "Pages des participants",
"count": "{{currentPage}} sur {{totalPageCount}}",
"next": "page suivante",
"previous": "page précédente"
@@ -290,11 +291,14 @@
"apply": "Remplacer votre arrière plan :",
"personal": {
"title": "Mes arrière-plans",
"selectedLabel": "Ne plus utiliser comme arrière-plan (actuellement actif).",
"apply": "Utiliser comme arrière plan.",
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
"uploadInProgress": "Image en cours d'envoi…",
"deleteLabel": "Supprimer l'arrière-plan.",
"errors": {
"close": "Fermer",
"file_too_large": {
@@ -428,7 +432,7 @@
}
},
"premium": {
"heading": "Fonctionnalité premium",
"heading": "Fonctionnalité avancée",
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
"linkMore": "Ouvrir la documentation",
"dividerLabel": "OU",
@@ -441,7 +445,7 @@
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
"buttonLabel": "Demander"
},
"linkAriaLabel": "Ouvrir la documentation sur l'accès premium - ouvre dans une nouvelle fenêtre"
"linkAriaLabel": "Ouvrir la documentation sur la fonctionnalité avancée - ouvre dans une nouvelle fenêtre"
}
},
"screenRecording": {
@@ -481,7 +485,7 @@
}
},
"premium": {
"heading": "Fonctionnalité premium",
"heading": "Fonctionnalité avancée",
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
"linkMore": "Ouvrir la documentation",
"dividerLabel": "OU",
@@ -494,7 +498,7 @@
"body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.",
"buttonLabel": "Demander"
},
"linkAriaLabel": "Ouvrir la documentation sur l'accès premium - ouvre dans une nouvelle fenêtre"
"linkAriaLabel": "Ouvrir la documentation sur la fonctionnalité avancée - ouvre dans une nouvelle fenêtre"
},
"durationMessage": "(limité à {{max_duration}}) "
},
@@ -12,6 +12,10 @@
"idleDisconnectModal": {
"label": "Quitter les appels sans autre participant",
"description": "Vous fait quitter un appel au bout de quelques minutes si aucun autre participant ne vous rejoint"
},
"autoMuteLargeRoom": {
"label": "Couper automatiquement mon micro",
"description": "Coupe automatiquement votre micro lorsque vous rejoignez une réunion de grande taille."
}
},
"audio": {
@@ -14,6 +14,10 @@
"auto": "Het lijkt erop dat u bent begonnen te spreken, dus we laten uw hand zakken.",
"dismiss": "Houdt uw hand opgestoken"
},
"autoMuteLargeRoom": {
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
"dismiss": "Microfoon inschakelen"
},
"reaction": {
"description": "{{name}} reageerde met {{emoji}}"
},
+8 -4
View File
@@ -97,6 +97,7 @@
}
},
"pagination": {
"label": "Pagina's van deelnemers",
"count": "{{currentPage}} van {{totalPageCount}}",
"next": "volgende pagina",
"previous": "vorige pagina"
@@ -290,11 +291,14 @@
"apply": "Vervang je achtergrond:",
"personal": {
"title": "Mijn achtergronden",
"selectedLabel": "Deselecteer de achtergrond (deze is momenteel toegepast).",
"apply": "Gebruik als achtergrond.",
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
"uploadInProgress": "Afbeelding wordt geüpload…",
"deleteLabel": "Aangepaste achtergrond verwijderen.",
"errors": {
"close": "Sluiten",
"file_too_large": {
@@ -428,10 +432,10 @@
}
},
"premium": {
"heading": "Premiumfunctie",
"heading": "Geavanceerde functie",
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
"linkMore": "Documentatie openen",
"linkAriaLabel": "Documentatie over premiumtoegang openen - opent in nieuw venster",
"linkAriaLabel": "Documentatie over Geavanceerde functie openen - opent in nieuw venster",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
@@ -481,10 +485,10 @@
}
},
"premium": {
"heading": "Premiumfunctie",
"heading": "Geavanceerde functie",
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
"linkMore": "Documentatie openen",
"linkAriaLabel": "Documentatie over premiumtoegang openen - opent in nieuw venster",
"linkAriaLabel": "Documentatie over Geavanceerde functie openen - opent in nieuw venster",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
@@ -12,6 +12,10 @@
"idleDisconnectModal": {
"label": "Verlaat oproepen zonder deelnemers",
"description": "Verlaat automatisch een oproep na een paar minuten als geen andere deelnemer meedoet"
},
"autoMuteLargeRoom": {
"label": "Mijn microfoon automatisch dempen",
"description": "Uw microfoon wordt automatisch gedempt wanneer u deelneemt aan een grote vergadering."
}
},
"audio": {
@@ -3,10 +3,12 @@ import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
is_idle_disconnect_modal_enabled: boolean
is_auto_mute_large_room_enabled: boolean
}
const DEFAULT_STATE = {
is_idle_disconnect_modal_enabled: true,
is_auto_mute_large_room_enabled: true,
}
function getUserPreferencesState(): State {
+7 -1
View File
@@ -37,7 +37,7 @@ _summaryEnvVars: &summaryEnvVars
secretKeyRef:
name: secret-dev
key: WHISPERX_BASE_URL
WHISPERX_ASR_MODEL: large-v2
WHISPERX_ASR_MODEL: openai/whisper-large-v3
WHISPERX_DEFAULT_LANGUAGE: fr
LLM_BASE_URL:
secretKeyRef:
@@ -53,6 +53,12 @@ _summaryEnvVars: &summaryEnvVars
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED: true
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP: 0.5
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS: true
RESOLVE_SPEAKER_MAX_WORD_DURATION: 1
POSTHOG_API_KEY: your-posthog-key
POSTHOG_ENABLED: False
_summaryImage: &summaryImage
repository: localhost:5001/meet-summary
@@ -23,6 +23,9 @@ frontend:
- name: outlook-addon-manifest
configMap:
name: outlook-addon-manifest
- name: frontend-nginx-config
configMap:
name: frontend-nginx-config
extraVolumeMounts:
- name: outlook-addon-config
@@ -33,9 +36,18 @@ frontend:
mountPath: /usr/share/nginx/html/addons/outlook/manifest.xml
subPath: manifest.xml
readOnly: true
- name: frontend-nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
readOnly: true
outlookAddon:
enabled: true
baseUrl: "https://meet.127.0.0.1.nip.io"
enableSourceTracking: true
feedbackForm: "https://tests.com/"
appName: "Visio"
id: "a025f0f6-757a-4790-97f3-99c66c4a5795"
frontendNginxConfig:
enabled: true
@@ -28,6 +28,7 @@ agentSubtitles:
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
STT_PROVIDER: kyutai
image:
repository: localhost:5001/meet-agents
@@ -53,3 +54,6 @@ agentSubtitles:
items:
- key: cacert.pem
path: cert.pem
frontendNginxConfig:
enabled: false
@@ -0,0 +1,94 @@
{{- if .Values.frontendNginxConfig.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-nginx-config
namespace: {{ .Release.Namespace }}
data:
default.conf: |
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
{{- end }}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.22
version: 0.0.26

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