mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 | |||
| e273ac9e43 | |||
| adcdbc0695 | |||
| 8f9008f1e0 | |||
| 9320a1af0c | |||
| e3827970c8 | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 | |||
| d43b335546 | |||
| e6f0ff5112 | |||
| 224d6ce358 | |||
| 20718f2d5a | |||
| 8edcb6e973 | |||
| b00c4e5d6d | |||
| 3c0ba03976 | |||
| 7fa172d100 | |||
| ecf8f0fe3f | |||
| 27da57aff5 | |||
| e327a5e35f | |||
| c3abd0441f | |||
| 79245389ce | |||
| 3b3f992834 | |||
| a98dc1484a | |||
| dca24a1b25 | |||
| 74b791e207 | |||
| df1495c97b | |||
| c95e1c67bd | |||
| f115c83752 | |||
| ebfcb42a7d |
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v5
|
||||
@@ -305,7 +305,6 @@ jobs:
|
||||
working-directory: src/summary
|
||||
|
||||
env:
|
||||
V1_TENANT_ID: 'test-tenant'
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
|
||||
@@ -8,6 +8,87 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||
- ✨(all) allow forcing SSO display name for authenticated users
|
||||
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||
- ✨(addon) show add-in tools when creating meetings in shared calendars
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||
- ♻️(frontend) refactor background processors to use the new API
|
||||
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) identify externally provisioned users to PostHog
|
||||
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||
- ♿️(frontend) focus side panel container on open #1452
|
||||
- 🐛(summary) whisper call error handling
|
||||
|
||||
## [1.23.0] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) extend analytics module to support feature flags
|
||||
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(agents) upgrade to python 3.14 slim
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
- 💥(summary) remove v1 related code #1362
|
||||
- ✨(meet) use compatible with summary v2 #1362
|
||||
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||
- 🔥(summary) remove call to summary enabled feature flag
|
||||
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||
- ⬆️(frontend) update the frontend build image to Node 22
|
||||
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||
- ✨(summary) more precise analytics events
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚀(front) fix frontend build failure
|
||||
- 🐛(makefile) fix args in make test
|
||||
- 🩹(backend) fix case-insensitive email deduplication in merge command
|
||||
- 🐛(summary) support media files with bad streams #1478
|
||||
|
||||
## [1.22.0] - 2026-07-03
|
||||
|
||||
### Added
|
||||
@@ -19,6 +100,7 @@ and this project adheres to
|
||||
- ✨(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
|
||||
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -26,6 +108,7 @@ and this project adheres to
|
||||
- ♻️(env) refactor env variables handling
|
||||
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
||||
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
||||
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
|
||||
@@ -211,24 +211,25 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
@$(MAKE) test-summary
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||
$(MAKE) test-summary ARGS="$${args}"
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args:-${1}}
|
||||
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args}
|
||||
.PHONY: test-back
|
||||
|
||||
test-back-parallel: ## run all back-end tests in parallel
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
test-summary: ## run summary tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args:-${1}}
|
||||
test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args}
|
||||
.PHONY: test-summary
|
||||
|
||||
makemigrations: ## run django makemigrations for the Meet project.
|
||||
|
||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
||||
(Note : in your development environment, you can `make migrate`.)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v1.23.0
|
||||
|
||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||
|
||||
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||
|
||||
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||
|
||||
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
||||
# Update agents pyproject.toml
|
||||
update_python_version "agents"
|
||||
|
||||
# Run uv lock in agents
|
||||
print_info "Running uv lock in agents..."
|
||||
cd "src/agents"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update CHANGELOG
|
||||
print_info "Updating CHANGELOG..."
|
||||
|
||||
@@ -158,6 +164,7 @@ echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - src/agents/uv.lock"
|
||||
echo " - CHANGELOG.md"
|
||||
echo ""
|
||||
print_warning "Next steps:"
|
||||
|
||||
+2
-2
@@ -301,7 +301,7 @@ services:
|
||||
context: ./src/summary
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
volumes:
|
||||
@@ -321,7 +321,7 @@ services:
|
||||
context: ./src/summary
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---- Front-end image ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -344,6 +344,7 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
|
||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
||||
|
||||
### How to Use
|
||||
|
||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
|
||||
|
||||
```javascript
|
||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
||||
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||
|
||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||
|
||||
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
||||
}
|
||||
```
|
||||
|
||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||
|
||||
@@ -65,8 +65,9 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
RECORDING_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
|
||||
@@ -9,3 +9,6 @@ DEEPGRAM_API_KEY=
|
||||
|
||||
KYUTAI_STT_BASE_URL=
|
||||
KYUTAI_API_KEY=
|
||||
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=
|
||||
|
||||
@@ -30,3 +30,4 @@ POSTHOG_ENABLED="False"
|
||||
|
||||
# Transcription
|
||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
|
||||
|
||||
@@ -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.2.0</Version>
|
||||
<Version>1.0.0.0</Version>
|
||||
<ProviderName>__APP_NAME__</ProviderName>
|
||||
<DefaultLocale>fr-FR</DefaultLocale>
|
||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||
@@ -205,5 +205,174 @@
|
||||
</bt:String>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
|
||||
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
|
||||
<Requirements>
|
||||
<bt:Sets DefaultMinVersion="1.8">
|
||||
<bt:Set Name="Mailbox"/>
|
||||
</bt:Sets>
|
||||
</Requirements>
|
||||
<Hosts>
|
||||
<Host xsi:type="MailHost">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Commands.Url"/>
|
||||
<SupportsSharedFolders>true</SupportsSharedFolders>
|
||||
|
||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgReadGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<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="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<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>
|
||||
</Action>
|
||||
</Control>
|
||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="apptComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<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>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||
<Label resid="OpenSettings.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="OpenSettings.Label"/>
|
||||
<Description resid="OpenSettings.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
|
||||
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
|
||||
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
|
||||
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
|
||||
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
|
||||
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
|
||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<!-- Default (French) -->
|
||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||
<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="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>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "^26.3.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"version": "26.3.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
||||
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "26.3.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
data-i18n="footer.feedback"
|
||||
></a>
|
||||
<div id="footer-right">
|
||||
<span class="version-badge">beta</span>
|
||||
<span class="version-number">0.0.2</span>
|
||||
<span class="version-number">1.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13.13-slim AS base
|
||||
FROM python:3.14.6-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
@@ -32,6 +32,8 @@ from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
from observability import configure_sentry, set_job_context
|
||||
from tasks import done_callback
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
configure_sentry(AGENT_NAME)
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
@@ -174,7 +177,13 @@ class MetadataCollector:
|
||||
self.on_chat_message_received(reader, participant_identity)
|
||||
)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"process chat stream from {participant_identity}",
|
||||
)
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
@@ -270,16 +279,18 @@ class MetadataCollector:
|
||||
logger.info("Participant disconnected: %s", participant.identity)
|
||||
task = asyncio.create_task(self._close_session(session))
|
||||
self._tasks.add(task)
|
||||
|
||||
def on_close_done(_):
|
||||
self._tasks.discard(task)
|
||||
logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"close VAD session for {participant.identity}",
|
||||
on_success=lambda _: logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
),
|
||||
)
|
||||
|
||||
task.add_done_callback(on_close_done)
|
||||
)
|
||||
|
||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||
"""Update stored participant name when it changes."""
|
||||
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
|
||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the metadata collector."""
|
||||
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||
|
||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||
recording_id = ctx.job.metadata
|
||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize Sentry for the worker process. Each job runs in its own
|
||||
# (forked) process and re-initializes Sentry via prewarm().
|
||||
configure_sentry(AGENT_NAME)
|
||||
cli.run_app(server)
|
||||
|
||||
@@ -25,6 +25,9 @@ from livekit.agents import (
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
from observability import configure_sentry, set_job_context
|
||||
from tasks import done_callback
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("transcriber")
|
||||
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
|
||||
logger.info(f"starting session for {participant.identity}")
|
||||
task = asyncio.create_task(self._start_session(participant))
|
||||
self._tasks.add(task)
|
||||
|
||||
def on_task_done(task: asyncio.Task):
|
||||
try:
|
||||
self._sessions[participant.identity] = task.result()
|
||||
finally:
|
||||
self._tasks.discard(task)
|
||||
|
||||
task.add_done_callback(on_task_done)
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"start transcription session for {participant.identity}",
|
||||
)
|
||||
)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle participant disconnection by closing transcription session."""
|
||||
if (session := self._sessions.pop(participant.identity)) is None:
|
||||
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||
return
|
||||
|
||||
logger.info(f"closing session for {participant.identity}")
|
||||
task = asyncio.create_task(self._close_session(session))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda _: self._tasks.discard(task))
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"close transcription session for {participant.identity}",
|
||||
)
|
||||
)
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start transcription session for participant."""
|
||||
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
|
||||
participant_identity=participant.identity,
|
||||
)
|
||||
)
|
||||
self._sessions[participant.identity] = session
|
||||
return session
|
||||
|
||||
async def _close_session(self, sess: AgentSession) -> None:
|
||||
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
|
||||
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the multi-user transcriber."""
|
||||
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||
|
||||
transcriber = MultiUserTranscriber(ctx)
|
||||
transcriber.start()
|
||||
|
||||
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||
if ENABLE_SILERO_VAD:
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize Sentry for the worker process. Each job runs in its own
|
||||
# (forked) process and re-initializes Sentry via prewarm().
|
||||
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||
cli.run_app(
|
||||
WorkerOptions(
|
||||
entrypoint_fnc=entrypoint,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Sentry helpers for the LiveKit agents."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tomllib
|
||||
from os import path
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
logger = logging.getLogger("observability")
|
||||
|
||||
BASE_DIR = path.dirname(path.abspath(__file__))
|
||||
|
||||
|
||||
def get_release():
|
||||
"""Get the current release of the application.
|
||||
|
||||
By release, we mean the ``version`` declared in ``pyproject.toml``.
|
||||
If the file cannot be read or declares no version, it defaults to "NA".
|
||||
"""
|
||||
try:
|
||||
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||
return tomllib.load(pyproject)["project"]["version"]
|
||||
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
def configure_sentry(agent_name: str) -> None:
|
||||
"""Initialize Sentry for the current agent process.
|
||||
|
||||
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
|
||||
unconditionally so the calling process gets its own live transport.
|
||||
|
||||
Must be called once per process: in the worker entrypoint and again in the
|
||||
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
|
||||
forked process. A forked child inherits the parent's initialized Sentry
|
||||
client but not its background transport thread (threads do not survive
|
||||
``fork()``), so it must re-init to get a working transport. For that reason,
|
||||
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
|
||||
as ``True`` and would skip init, silently dropping every event.
|
||||
|
||||
Args:
|
||||
agent_name: Identifier of the agent, attached as a tag to Sentry issues
|
||||
"""
|
||||
# Read the DSN at call time so it picks up variables that load_dotenv()
|
||||
# populated after this module was first imported.
|
||||
sentry_dsn = os.getenv("SENTRY_DSN")
|
||||
if not sentry_dsn:
|
||||
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
|
||||
return
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_dsn,
|
||||
environment=os.getenv("SENTRY_ENVIRONMENT"),
|
||||
release=get_release(),
|
||||
debug=False,
|
||||
integrations=[
|
||||
# Capture log records emitted at ERROR and above as Sentry events.
|
||||
# This covers the agents' explicit logger.exception(...) calls as
|
||||
# well as asyncio's "Exception in callback" / "Task exception was
|
||||
# never retrieved" records, so unhandled task failures surface too.
|
||||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||
],
|
||||
)
|
||||
sentry_sdk.set_tag("application", "agents")
|
||||
sentry_sdk.set_tag("agent", agent_name)
|
||||
|
||||
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
|
||||
|
||||
|
||||
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
|
||||
"""Tag the current Sentry scope with the LiveKit job being handled.
|
||||
|
||||
Args:
|
||||
room: Name of the room the job is serving.
|
||||
job_id: LiveKit job identifier.
|
||||
"""
|
||||
scope = sentry_sdk.get_current_scope()
|
||||
if room is not None:
|
||||
scope.set_tag("room", room)
|
||||
if job_id is not None:
|
||||
scope.set_tag("job_id", job_id)
|
||||
@@ -1,21 +1,22 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.22.0"
|
||||
version = "1.24.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.5.13",
|
||||
"livekit-plugins-deepgram==1.5.13",
|
||||
"livekit-plugins-silero==1.5.13",
|
||||
"livekit-agents==1.6.4",
|
||||
"livekit-plugins-deepgram==1.6.4",
|
||||
"livekit-plugins-silero==1.6.4",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20"
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.60.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.19",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Helpers for managing asyncio tasks."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
def done_callback(
|
||||
logger: logging.Logger,
|
||||
tasks: set[asyncio.Task],
|
||||
description: str,
|
||||
*,
|
||||
on_success: Callable[[Any], None] | None = None,
|
||||
) -> Callable[[asyncio.Task], None]:
|
||||
"""Build a done-callback for a background task.
|
||||
|
||||
Meant to be passed to `asyncio.Task.add_done_callback`.
|
||||
|
||||
Args:
|
||||
logger: Logger used to report failures, so records keep the caller's
|
||||
logger name.
|
||||
tasks: Set the task was registered in; the task is discarded from it.
|
||||
description: Human-readable intended action
|
||||
on_success: Optional callback invoked with the task's result when it
|
||||
completes without error.
|
||||
|
||||
Returns:
|
||||
A callback suitable for ``task.add_done_callback(...)``.
|
||||
"""
|
||||
|
||||
def _finalize(task: asyncio.Task) -> None:
|
||||
tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
if (exc := task.exception()) is not None:
|
||||
logger.exception("failed to %s", description, exc_info=exc)
|
||||
return
|
||||
if on_success is not None:
|
||||
on_success(task.result())
|
||||
|
||||
return _finalize
|
||||
Generated
+936
-832
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ from core.recording.event import notification
|
||||
|
||||
from . import models
|
||||
from .tasks.file import process_file_deletion
|
||||
from .utils import generate_download_file_url
|
||||
from .utils import generate_download_s3_url
|
||||
|
||||
|
||||
def hard_delete_file(file):
|
||||
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
"""Return a clickable preview URL for the file."""
|
||||
if not obj.is_ready:
|
||||
return "-"
|
||||
url = generate_download_file_url(obj, expires_in=60 * 60)
|
||||
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
||||
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
search_fields = [
|
||||
"status",
|
||||
"=id",
|
||||
"worker_id",
|
||||
"room__slug",
|
||||
"=room__id",
|
||||
"accesses__user__email",
|
||||
]
|
||||
list_display = (
|
||||
"id",
|
||||
"status",
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.utils.module_loading import import_string
|
||||
|
||||
from .base import AnalyticsBackend, NoOpAnalytics
|
||||
from .events import AnalyticsEvent
|
||||
from .user_feature_flags import UserFeatureFlag
|
||||
|
||||
__all__ = [
|
||||
"get_analytics",
|
||||
@@ -26,6 +27,8 @@ __all__ = [
|
||||
"capture",
|
||||
"AnalyticsBackend",
|
||||
"AnalyticsEvent",
|
||||
"is_user_feature_flag_enabled",
|
||||
"UserFeatureFlag",
|
||||
]
|
||||
|
||||
|
||||
@@ -57,3 +60,8 @@ def capture(
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
analytics_instance.capture(user, event, properties)
|
||||
|
||||
|
||||
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
|
||||
"""Check if a feature is enabled at the user level."""
|
||||
return analytics_instance.is_user_feature_enabled(user, feature_name)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Analytics backend protocol and default no-op implementation."""
|
||||
|
||||
from typing import Any, Protocol
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ..models import User
|
||||
from .events import AnalyticsEvent
|
||||
from .user_feature_flags import UserFeatureFlag
|
||||
|
||||
|
||||
class AnalyticsBackend(Protocol):
|
||||
class AnalyticsBackend(ABC):
|
||||
"""
|
||||
Interface every analytics backend must implement.
|
||||
|
||||
@@ -17,11 +19,11 @@ class AnalyticsBackend(Protocol):
|
||||
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits (email, name, ...) with an identified user."""
|
||||
|
||||
@abstractmethod
|
||||
def capture(
|
||||
self,
|
||||
user: User,
|
||||
@@ -30,16 +32,29 @@ class AnalyticsBackend(Protocol):
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
|
||||
@abstractmethod
|
||||
def shutdown(self) -> None:
|
||||
"""Flush pending events. Called on process exit."""
|
||||
|
||||
def get_user_feature_flags(
|
||||
self,
|
||||
user: User, # pylint: disable=unused-argument
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Return a dict of feature flags for the given user."""
|
||||
# We return an empty dict here by default to avoid a breaking change
|
||||
# By making this method abstract.
|
||||
return {}
|
||||
|
||||
class NoOpAnalytics:
|
||||
def is_user_feature_enabled(
|
||||
self, user: User, feature_name: UserFeatureFlag
|
||||
) -> bool:
|
||||
"""Check if a feature is enabled at the user level."""
|
||||
return self.get_user_feature_flags(user).get(feature_name, False) is True
|
||||
|
||||
|
||||
class NoOpAnalytics(AnalyticsBackend):
|
||||
"""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."""
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""PostHog implementation of the analytics backend protocol."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
from ..models import User
|
||||
from .base import AnalyticsBackend
|
||||
from .events import AnalyticsEvent
|
||||
from .user_feature_flags import UserFeatureFlag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostHogAnalytics:
|
||||
class PostHogAnalytics(AnalyticsBackend):
|
||||
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||
|
||||
def __init__(
|
||||
@@ -19,6 +23,8 @@ class PostHogAnalytics:
|
||||
*,
|
||||
api_key: str,
|
||||
host: str = "https://eu.i.posthog.com",
|
||||
feature_flags_cache_ttl: int = 60,
|
||||
feature_flags_cache_prefix: str = "user_feature_flags:",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
||||
@@ -29,6 +35,8 @@ class PostHogAnalytics:
|
||||
host=host,
|
||||
**kwargs,
|
||||
)
|
||||
self._feature_flags_cache_ttl = feature_flags_cache_ttl
|
||||
self._feature_flags_cache_prefix = feature_flags_cache_prefix
|
||||
|
||||
@staticmethod
|
||||
def _distinct_id(user: User) -> str | None:
|
||||
@@ -72,3 +80,37 @@ class PostHogAnalytics:
|
||||
def shutdown(self) -> None:
|
||||
"""Flush pending events. Called on process exit."""
|
||||
self._client.shutdown()
|
||||
|
||||
def _fetch_user_feature_flags(
|
||||
self, user: User
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Compute feature flags for a user."""
|
||||
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return {}
|
||||
|
||||
flags = self._client.evaluate_flags(distinct_id)
|
||||
out: dict[UserFeatureFlag, bool | str | None] = {}
|
||||
for flag_key in UserFeatureFlag:
|
||||
out[flag_key] = flags.get_flag(flag_key.value)
|
||||
|
||||
return out
|
||||
|
||||
def get_user_feature_flags(
|
||||
self, user: User
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Get feature flags for a user. Caches the result for a short time."""
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return cache.get_or_set(
|
||||
f"{self._feature_flags_cache_prefix}{distinct_id}",
|
||||
default=lambda: self._fetch_user_feature_flags(user),
|
||||
timeout=self._feature_flags_cache_ttl,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("Failed to get feature flags for user %s", user.pk)
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Catalog of all analytics feature flags used by the backend."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class UserFeatureFlag(StrEnum):
|
||||
"""All feature flags configured in the app."""
|
||||
|
||||
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
|
||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -563,3 +563,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
||||
"""Serializer for renaming a participant in a room."""
|
||||
|
||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||
|
||||
|
||||
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate external process event data."""
|
||||
|
||||
job_id = serializers.CharField(required=True)
|
||||
# We are not strict on purpose on those fields to avoid
|
||||
# useless bad requests
|
||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
@@ -39,7 +39,10 @@ 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
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.authentication import (
|
||||
RecordingProcessWebhookAuthentication,
|
||||
StorageEventAuthentication,
|
||||
)
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
@@ -89,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..models import RoomAccessLevel
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
@@ -264,6 +268,9 @@ class RoomViewSet(
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"is_administrable": False,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
@@ -999,6 +1006,60 @@ class RecordingViewSet(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="external-process-hook",
|
||||
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||
)
|
||||
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming external process events for recordings."""
|
||||
logger.debug("Processing external process event %s", request.data)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
ok_response = drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
job_id = validated_data["job_id"]
|
||||
try:
|
||||
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||
return ok_response
|
||||
|
||||
if validated_data.get("type") == "transcript":
|
||||
if validated_data.get("status") == "success":
|
||||
logger.info(
|
||||
"External process transcript success received for recording %s",
|
||||
job_id,
|
||||
)
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||
)
|
||||
recording.save()
|
||||
return ok_response
|
||||
|
||||
if validated_data.get("status") == "failure":
|
||||
logger.info(
|
||||
"External process transcript failure received for recording %s",
|
||||
job_id,
|
||||
)
|
||||
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||
recording.save()
|
||||
return ok_response
|
||||
|
||||
logger.info(
|
||||
"No changes to save for external process id %s and payload %s",
|
||||
job_id,
|
||||
validated_data,
|
||||
)
|
||||
|
||||
return ok_response
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
|
||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||
|
||||
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Merge duplicate users sharing the same email into the most recently created one.
|
||||
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||
most recently created one.
|
||||
|
||||
The KEPT user is the most recently created. All room memberships, recording
|
||||
accesses and files are transferred to it. When a conflict exists, the
|
||||
higher-privilege role wins. Stale users are then deleted.
|
||||
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||
recently created. All room memberships, recording accesses and files are
|
||||
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||
Stale users are then deleted.
|
||||
Each email group is processed inside a single database transaction.
|
||||
"""
|
||||
|
||||
@@ -56,13 +60,16 @@ class Command(BaseCommand):
|
||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||
|
||||
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||
# are detected as duplicates of each other.
|
||||
duplicate_emails = (
|
||||
users_qs.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.values("email")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower")
|
||||
.annotate(cnt=Count("id"))
|
||||
.filter(cnt__gt=1)
|
||||
.values_list("email", flat=True)
|
||||
.values_list("email_lower", flat=True)
|
||||
)
|
||||
|
||||
if not duplicate_emails:
|
||||
@@ -78,9 +85,12 @@ class Command(BaseCommand):
|
||||
failed_emails = []
|
||||
|
||||
for email in duplicate_emails:
|
||||
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||
# Secondary sort by id ensures a stable, deterministic order when
|
||||
# created_at timestamps are equal (common in tests and bulk imports).
|
||||
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
|
||||
users = list(
|
||||
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||
)
|
||||
kept_user = users[-1]
|
||||
stale_users = users[:-1]
|
||||
|
||||
@@ -120,6 +130,10 @@ class Command(BaseCommand):
|
||||
failed_emails.append(email)
|
||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
||||
|
||||
if not kept_user.email.islower():
|
||||
kept_user.email = kept_user.email.lower()
|
||||
kept_user.save(update_fields=["email"])
|
||||
|
||||
if failed_emails:
|
||||
raise CommandError(
|
||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-22 08:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_alter_file_upload_state'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recording',
|
||||
name='external_process_id',
|
||||
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
EXTERNAL_PROCESS_SUCCESSFUL = (
|
||||
"external_process_successful",
|
||||
_("External process successful"),
|
||||
)
|
||||
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
|
||||
|
||||
@classmethod
|
||||
def is_final(cls, status):
|
||||
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
@@ -598,6 +605,14 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Recording options"),
|
||||
help_text=_("Recording options"),
|
||||
)
|
||||
external_process_id = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
unique=True,
|
||||
verbose_name=_("External Process ID"),
|
||||
help_text=_("ID of the external process associated with the recording."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -653,6 +668,8 @@ class Recording(BaseModel):
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
||||
class MachineUser:
|
||||
"""Represent a non-interactive system user for automated storage operations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, username: str = "storage_event_user") -> None:
|
||||
self.pk = None
|
||||
self.username = "storage_event_user"
|
||||
self.username = username
|
||||
self.is_active = True
|
||||
|
||||
@property
|
||||
@@ -34,33 +34,33 @@ class MachineUser:
|
||||
return self.username
|
||||
|
||||
|
||||
class StorageEventAuthentication(BaseAuthentication):
|
||||
"""Authenticate requests using a Bearer token for storage event integration.
|
||||
This class validates Bearer tokens for storage events that don't map to database users.
|
||||
It's designed for S3-compatible storage integrations and similar use cases.
|
||||
Events are submitted when a webhook is configured on some bucket's events.
|
||||
"""
|
||||
class HeaderBasedAuthentication(BaseAuthentication):
|
||||
"""Authenticate requests using a header with a secret key."""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
REALM = ""
|
||||
|
||||
IS_ENFORCED_SETTINGS_KEY = None
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||
return MachineUser(), None
|
||||
|
||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
||||
if not required_token:
|
||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
raise AuthenticationFailed(
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
|
||||
return MachineUser(), None
|
||||
if (
|
||||
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||
is None
|
||||
):
|
||||
raise AuthenticationFailed(
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(
|
||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
)
|
||||
raise AuthenticationFailed("Authorization header is required")
|
||||
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
logger.warning(
|
||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
scheme, _, token = auth_header.partition(" ")
|
||||
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
|
||||
raise AuthenticationFailed("Invalid authorization header format.")
|
||||
token = token.strip()
|
||||
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
||||
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
||||
|
||||
|
||||
class StorageEventAuthentication(HeaderBasedAuthentication):
|
||||
"""Authenticate requests using a Bearer token for storage event integration.
|
||||
This class validates Bearer tokens for storage events that don't map to database users.
|
||||
It's designed for S3-compatible storage integrations and similar use cases.
|
||||
Events are submitted when a webhook is configured on some bucket's events.
|
||||
"""
|
||||
|
||||
REALM = "Storage event API"
|
||||
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
||||
|
||||
|
||||
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||
"""
|
||||
Custom authentication class for recording process webhook requests.
|
||||
Validates the API key in the Authorization header.
|
||||
"""
|
||||
|
||||
REALM = "External process webhook API"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
@@ -17,6 +18,8 @@ from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,8 +181,49 @@ class NotificationService:
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _generate_title(
|
||||
*,
|
||||
locale: str,
|
||||
room: str,
|
||||
recording_datetime: datetime | None,
|
||||
owner_timezone: str | None,
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if recording_datetime is None:
|
||||
with override(locale):
|
||||
return _("Transcription")
|
||||
|
||||
dt = recording_datetime
|
||||
if owner_timezone:
|
||||
try:
|
||||
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
||||
except (KeyError, ZoneInfoNotFoundError):
|
||||
pass # Keep the original UTC datetime
|
||||
|
||||
with override(locale):
|
||||
translated_template = _(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
)
|
||||
return translated_template.format(
|
||||
room=room,
|
||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||
room_recording_time=dt.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -253,5 +297,120 @@ class NotificationService:
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v2(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
metadata_filename: None | str = None
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
|
||||
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
||||
form_link = (
|
||||
f"{form_base_url}?room_id={recording.room.id}"
|
||||
if (form_base_url and metadata_filename is not None)
|
||||
else None
|
||||
)
|
||||
metadata_payload = None
|
||||
if started_at and ended_at and metadata_filename:
|
||||
metadata_payload = {
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
metadata_filename,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
),
|
||||
"started_at": started_at.isoformat(),
|
||||
"ended_at": ended_at.isoformat(),
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
),
|
||||
"language": recording.options.get(
|
||||
"language", get_language().split("-")[0].lower()
|
||||
),
|
||||
"context_language": owner_access.user.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner_access.user.email,
|
||||
"title": NotificationService._generate_title(
|
||||
locale=owner_access.user.language
|
||||
or recording.options.get("language", get_language()),
|
||||
room=recording.room.name,
|
||||
recording_datetime=started_at,
|
||||
owner_timezone=str(owner_access.user.timezone),
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
# We do not require a job_id to avoid a breaking change
|
||||
job_id = response_json.get("job_id")
|
||||
if not isinstance(job_id, str):
|
||||
raise ValueError("job_id is not a string")
|
||||
|
||||
recording.external_process_id = job_id
|
||||
recording.save()
|
||||
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
|
||||
from core.analytics.events import AnalyticsEvent
|
||||
from core.analytics.posthog import PostHogAnalytics
|
||||
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -248,6 +249,60 @@ def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, capl
|
||||
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
# ==============================
|
||||
# feature flags
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
|
||||
"""Should map every declared feature flag key to the SDK evaluated value."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
|
||||
True
|
||||
)
|
||||
|
||||
flags = backend._fetch_user_feature_flags(user)
|
||||
|
||||
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
|
||||
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
|
||||
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
|
||||
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.cache.get_or_set")
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_get_feature_flags_uses_cache_get_or_set(
|
||||
mock_posthog_cls, mock_cache_get_or_set
|
||||
):
|
||||
"""Should cache feature flags by user distinct id with configured TTL."""
|
||||
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
|
||||
mock_cache_get_or_set.return_value = cached_flags
|
||||
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
|
||||
user = UserFactory()
|
||||
|
||||
flags = backend.get_user_feature_flags(user)
|
||||
|
||||
assert flags == cached_flags
|
||||
mock_cache_get_or_set.assert_called_once()
|
||||
args, kwargs = mock_cache_get_or_set.call_args
|
||||
assert kwargs["timeout"] == 120
|
||||
assert args[0] == f"user_feature_flags:{user.pk}"
|
||||
assert callable(kwargs["default"])
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
|
||||
"""Should swallow failures and return an empty mapping."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
|
||||
assert backend.get_user_feature_flags(user) == {}
|
||||
|
||||
|
||||
# ==============================
|
||||
# shutdown
|
||||
# ==============================
|
||||
|
||||
@@ -36,6 +36,26 @@ def test_merge_keeps_most_recently_created_user():
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
def test_merge_user_case_insensitive():
|
||||
"""Emails differing only by case should be treated as duplicates and merged,
|
||||
keeping the most recently created user."""
|
||||
user1 = UserFactory(email="Dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
|
||||
assert not User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
user3 = UserFactory(email="joe@example.com")
|
||||
user4 = UserFactory(email="Joe@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
assert not User.objects.filter(id=user3.id).exists()
|
||||
assert User.objects.filter(id=user4.id).exists()
|
||||
|
||||
user4.refresh_from_db()
|
||||
assert user4.email.islower()
|
||||
|
||||
|
||||
def test_merge_deletes_all_stale_users():
|
||||
"""Command should delete all stale users and keep only the most recently created one."""
|
||||
email = "many@example.com"
|
||||
@@ -457,4 +477,4 @@ def test_merge_email_filter_is_case_insensitive():
|
||||
UserFactory(email="user1@Example.com")
|
||||
UserFactory(email="user1@Example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||
assert User.objects.filter(email="user1@Example.com").count() == 1
|
||||
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||
|
||||
@@ -136,10 +136,10 @@ def test_authenticate_header():
|
||||
|
||||
|
||||
def test_multiple_spaces_in_auth_header(settings):
|
||||
"""Test failure when Authorization header contains multiple spaces."""
|
||||
"""Test success when Authorization header contains multiple spaces."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
header = StorageEventAuthentication().authenticate_header(request)
|
||||
assert header == "Bearer realm='Storage event API'"
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.contrib.sites.models import Site
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -243,3 +244,177 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
assert result is False
|
||||
assert mock_send_mail.call_count == 2
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_post_args_with_metadata(
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
settings,
|
||||
):
|
||||
"""Test summary notification computed request args when metadata is enabled."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||
settings.SCREEN_RECORDING_BASE_URL = None
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
room__name="Engineering Sync",
|
||||
worker_id="egress-1",
|
||||
options={"collect_metadata": True, "language": "en-us"},
|
||||
)
|
||||
owner = factories.UserFactory(
|
||||
email="owner@test.com",
|
||||
sub="owner-sub",
|
||||
language="fr-fr",
|
||||
timezone="Europe/Paris",
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||
)
|
||||
|
||||
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
|
||||
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
|
||||
mock_get_recording_timestamps.return_value = (started_at, ended_at)
|
||||
mock_generate_download_s3_url.side_effect = [
|
||||
"https://storage.test/metadata.json",
|
||||
"https://storage.test/recording.ogg",
|
||||
]
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"job_id": "job-42"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = NotificationService._notify_summary_service(recording)
|
||||
recording.refresh_from_db()
|
||||
|
||||
assert result is True
|
||||
assert recording.external_process_id == "job-42"
|
||||
|
||||
metadata_filename = (
|
||||
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
|
||||
)
|
||||
expected_payload = {
|
||||
"user_sub": owner.sub,
|
||||
"user_email": owner.email,
|
||||
"cloud_storage_url": "https://storage.test/recording.ogg",
|
||||
"language": "en-us",
|
||||
"context_language": owner.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner.email,
|
||||
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
|
||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||
"auto_create_summary": False,
|
||||
"form_link": None,
|
||||
},
|
||||
"metadata": {
|
||||
"cloud_storage_url": "https://storage.test/metadata.json",
|
||||
"started_at": started_at.isoformat(),
|
||||
"ended_at": ended_at.isoformat(),
|
||||
},
|
||||
}
|
||||
expected_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer summary-token",
|
||||
}
|
||||
mock_post.assert_called_once_with(
|
||||
"https://summary.test/api/v2/tasks",
|
||||
json=expected_payload,
|
||||
headers=expected_headers,
|
||||
timeout=30,
|
||||
)
|
||||
assert mock_generate_download_s3_url.call_args_list == [
|
||||
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
||||
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
||||
]
|
||||
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
|
||||
def test_notify_summary_service_post_args_without_metadata(
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
auto_create_summary_enabled,
|
||||
settings,
|
||||
):
|
||||
"""Test summary notification computed request args when metadata is not available."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||
settings.SCREEN_RECORDING_BASE_URL = None
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Daily")
|
||||
owner = factories.UserFactory(
|
||||
email="owner@test.com",
|
||||
sub="owner-sub",
|
||||
language="en-us",
|
||||
timezone="UTC",
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||
)
|
||||
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"job_id": "job-51"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||
return_value=auto_create_summary_enabled,
|
||||
) as mock_is_feature_flag_enabled:
|
||||
result = NotificationService._notify_summary_service(recording)
|
||||
|
||||
assert result is True
|
||||
expected_payload = {
|
||||
"user_sub": owner.sub,
|
||||
"user_email": owner.email,
|
||||
"cloud_storage_url": "https://storage.test/recording.mp4",
|
||||
"language": "en",
|
||||
"context_language": owner.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner.email,
|
||||
"title": "Transcription",
|
||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||
"auto_create_summary": auto_create_summary_enabled,
|
||||
"form_link": None,
|
||||
},
|
||||
"metadata": None,
|
||||
}
|
||||
expected_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer summary-token",
|
||||
}
|
||||
mock_post.assert_called_once_with(
|
||||
"https://summary.test/api/v2/tasks",
|
||||
json=expected_payload,
|
||||
headers=expected_headers,
|
||||
timeout=30,
|
||||
)
|
||||
mock_generate_download_s3_url.assert_called_once_with(
|
||||
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||
)
|
||||
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
||||
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Test recordings API endpoints: external process hook.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import pytest
|
||||
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def external_process_settings(settings):
|
||||
"""Configure authentication token for the external process webhook."""
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
|
||||
return settings
|
||||
|
||||
|
||||
def test_external_process_event_missing_authorization_header(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""Requests without authorization must be rejected."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
|
||||
"""Requests with invalid bearer token must be rejected."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_external_process_event_missing_job_id(external_process_settings, client):
|
||||
"""Payload without job_id must fail validation."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"job_id": ["This field is required."]}
|
||||
|
||||
|
||||
def test_external_process_event_success_updates_recording_status(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""A successful transcript process should update recording status."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED,
|
||||
external_process_id="job-123",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-123", "type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||
|
||||
|
||||
def test_external_process_event_failure_updates_recording_status(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""A failing transcript process should update recording status."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED,
|
||||
external_process_id="job-456",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-456", "type": "transcript", "status": "failure"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||
|
||||
|
||||
def test_external_process_event_unknown_recording_is_ignored(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""Unknown job_id should not fail the webhook processing."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "missing-job", "type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
|
||||
def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""Only transcript events should update recording status."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED,
|
||||
external_process_id="job-789",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "unregistered-room",
|
||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "reunion",
|
||||
|
||||
@@ -2,13 +2,109 @@
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||
from core.factories import UserFactory
|
||||
from core.utils import (
|
||||
NotificationError,
|
||||
create_livekit_client,
|
||||
generate_token,
|
||||
notify_participants,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode a LiveKit JWT access token for inspection."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_uses_full_name():
|
||||
"""The token's display name should default to the user's full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
assert claims["sub"] == str(user.sub)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_fallback_user_representation():
|
||||
"""
|
||||
When the user has no full name, the token's display name should fall back
|
||||
to the user's string representation.
|
||||
"""
|
||||
user = UserFactory(full_name=None)
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == str(user)
|
||||
|
||||
|
||||
def test_generate_token_explicit_username_overrides_default():
|
||||
"""An explicitly provided username should take precedence over the full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Custom Name"
|
||||
|
||||
|
||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||
"""With editing disabled, an authenticated user's username is ignored."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||
"""Disabling editing doesn't disturb the default full-name path."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user)
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_anonymous_uses_username_when_provided():
|
||||
"""An anonymous user's provided username is used as the display name."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_falls_back_to_anonymous_label():
|
||||
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser())
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Anonymous"
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
|
||||
@@ -109,11 +109,16 @@ def generate_token(
|
||||
default_username = "Anonymous"
|
||||
else:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
default_username = user.full_name or str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
can_edit = (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||
)
|
||||
display_name = (username or default_username) if can_edit else default_username
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -121,7 +126,7 @@ def generate_token(
|
||||
)
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
@@ -459,12 +464,14 @@ def generate_upload_policy(file):
|
||||
return policy
|
||||
|
||||
|
||||
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
||||
def generate_download_s3_url(
|
||||
key: str, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
"""
|
||||
Generate a S3 signed download url for a given file.
|
||||
Generate a S3 signed download url for a given key.
|
||||
"""
|
||||
|
||||
key = file.file_key
|
||||
if not key:
|
||||
raise ValueError("key cannot be empty")
|
||||
|
||||
# This setting should be used if the backend application and the frontend application
|
||||
# can't connect to the object storage with the same domain. This is the case in the
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,70 +17,92 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Informationen"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Keine Scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -88,17 +110,17 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||
|
||||
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Externer Prozess erfolgreich"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Externer Prozess fehlgeschlagen"
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -198,11 +228,11 @@ msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -210,55 +240,55 @@ msgstr ""
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -266,66 +296,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -334,141 +364,153 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Bereit"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Hintergrundbild"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Datei"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Dateien"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
#, fuzzy
|
||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
@@ -476,10 +518,20 @@ msgstr ""
|
||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||
"sein."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transkription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -575,7 +627,7 @@ msgid "To keep this recording permanently:"
|
||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
@@ -604,18 +656,24 @@ msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,70 +17,92 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Personal info"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Resend notification to external service"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Failed to notify for recording %(id)s"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Mark selected recordings as 'Failed to Stop'"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "No scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -88,15 +110,15 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Creator is me"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "You have reached the maximum number of files for this type."
|
||||
|
||||
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "External process successful"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "External process failed"
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -196,11 +226,11 @@ msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -208,55 +238,55 @@ msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -264,63 +294,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||
|
||||
#: core/models.py:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -328,154 +358,175 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID of the external process associated with the recording."
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
#, fuzzy
|
||||
#| msgid "Recording"
|
||||
msgid "Pending"
|
||||
msgstr "Pending"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Ready"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Background image"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "title"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Malware detection info when the analysis status is unsafe."
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Files"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "This file is already hard deleted."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr "To hard delete a file, it must first be soft deleted."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -571,7 +622,7 @@ msgid "To keep this recording permanently:"
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
@@ -600,18 +651,24 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,71 +17,93 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Renvoyer la notification au service externe"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr ""
|
||||
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Aucun scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -89,17 +111,17 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis le créateur"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||
|
||||
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Traitement externe : succès"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Traitement externe : erreur"
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -201,11 +231,11 @@ msgstr ""
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||
"chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -213,55 +243,55 @@ msgstr ""
|
||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -269,65 +299,65 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -335,153 +365,174 @@ msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "ID Traitement externe"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID du traitement externe associé avec l'enregistrement."
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Image de fond"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Fichiers"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Ce fichier a été supprimé."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||
"comme supprimé (soft delete)"
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -577,7 +628,7 @@ msgid "To keep this recording permanently:"
|
||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
@@ -606,18 +657,24 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,70 +17,92 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Rechten"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Meerdere eigenaren"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Geen scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -88,16 +110,16 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Maker ben ik"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||
|
||||
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Externe procedure succesvol"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Het externe proces is mislukt."
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -197,11 +227,11 @@ msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -209,55 +239,55 @@ msgstr ""
|
||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -265,64 +295,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||
|
||||
#: core/models.py:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -330,140 +360,152 @@ msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "Externe proces-ID"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "In afwachting"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Achtergrondafbeelding"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Bestand"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Bestanden"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Dit bestand is al definitief verwijderd."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
#, fuzzy
|
||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
@@ -471,10 +513,20 @@ msgstr ""
|
||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||
"zijn."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcriptie"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -570,7 +622,7 @@ msgid "To keep this recording permanently:"
|
||||
msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
@@ -599,18 +651,24 @@ msgstr ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
@@ -397,6 +398,9 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
@@ -670,6 +674,11 @@ class Base(Configuration):
|
||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||
True,
|
||||
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Recording settings
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
@@ -748,12 +757,30 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
|
||||
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
|
||||
values.PositiveIntegerValue(
|
||||
60 * 60 * 24,
|
||||
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
)
|
||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
|
||||
None,
|
||||
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||
)
|
||||
@@ -1110,11 +1137,25 @@ class Base(Configuration):
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
):
|
||||
warnings.warn(
|
||||
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||
"The legacy v1 API has been removed from the experimental "
|
||||
"Summary service. Please update your Summary service deployment to "
|
||||
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||
# We use UserWarning to make sure it shows up in production deployment
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
dsn=cls.SENTRY_DSN,
|
||||
environment=cls.__name__.lower(),
|
||||
environment=cls.__name__.lower(), # build, test, development, production
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
|
||||
+13
-13
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.22.0"
|
||||
version = "1.24.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = "MIT"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.43.14",
|
||||
"boto3==1.43.36",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.6.3",
|
||||
"dj-database-url==3.1.2",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-countries==9.0.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.26",
|
||||
"django-lasuite[all]==0.0.27",
|
||||
"django-parler==2.4",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
"django-redis==7.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
@@ -57,13 +57,13 @@ dependencies = [
|
||||
"python-frontmatter==1.3.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk==2.60.0",
|
||||
"sentry-sdk==2.63.0",
|
||||
"whitenoise==6.12.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.14.0",
|
||||
"livekit-api==1.1.1",
|
||||
"aiohttp==3.14.1",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.31",
|
||||
"phonenumbers==9.0.33",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -75,20 +75,20 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.5.1",
|
||||
"drf-spectacular-sidecar==2026.6.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.13.0",
|
||||
"ipython==9.14.1",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.3",
|
||||
"pytest==9.1.1",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.1",
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.19",
|
||||
"types-requests==2.33.0.20260518",
|
||||
]
|
||||
|
||||
|
||||
Generated
+508
-517
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
USER node
|
||||
|
||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
Generated
+235
-1705
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -17,31 +17,33 @@
|
||||
"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",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-aria/toast": "3.0.10",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.382.0",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.14.0",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -52,6 +54,7 @@
|
||||
"@tanstack/eslint-plugin-query": "5.100.14",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/humanize-duration": "3.27.4",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
@@ -68,6 +71,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ const config: Config = {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
overlayIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 0.6 },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
@@ -58,6 +59,7 @@ export interface ApiConfig {
|
||||
transcription_destination?: string
|
||||
max_participants_for_sound: number
|
||||
auto_mute_on_join_threshold: number
|
||||
authenticated_users_can_edit_display_name: boolean
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
subtitles: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
subtitles: { width: '40px', height: '40px' },
|
||||
list: { width: '32px', height: '32px' },
|
||||
placeholder: { width: '100%', height: '100%' },
|
||||
},
|
||||
notification: {
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
true: { border: '2px solid white' },
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -49,32 +33,35 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = ({
|
||||
name,
|
||||
bgColor,
|
||||
context,
|
||||
notification,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
}}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initial}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Avatar.displayName = 'Avatar'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text } from '@/primitives'
|
||||
import { ChatMessages } from './ChatMessages'
|
||||
import { ChatTextArea } from './ChatTextArea'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const ChatContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
padding: '0 1.5rem',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const ChatMessagesContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const TextContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'greyscale.50',
|
||||
borderRadius: 4,
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
||||
export const Chat = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
|
||||
return (
|
||||
<ChatContainer>
|
||||
<TextContainer>
|
||||
<Text variant="sm">{t('disclaimer')}</Text>
|
||||
</TextContainer>
|
||||
<ChatMessagesContainer>
|
||||
<ChatMessages />
|
||||
</ChatMessagesContainer>
|
||||
<ChatTextArea />
|
||||
</ChatContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ChatRow } from '@/stores/chat'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ChatMessageMetadata } from './ChatMessageMedata'
|
||||
import { ChatMessageBody } from './ChatMessageBody'
|
||||
|
||||
const StyledContainer = styled('li', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
type ChatMessageProps = {
|
||||
item: ChatRow
|
||||
}
|
||||
|
||||
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
||||
const time = new Date(item.timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
return (
|
||||
<StyledContainer
|
||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||
>
|
||||
{!item.hideMetadata && (
|
||||
<ChatMessageMetadata
|
||||
timestamp={item.timestamp}
|
||||
identity={item.identity}
|
||||
/>
|
||||
)}
|
||||
<ChatMessageBody message={item.message} />
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ChatRow } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
||||
|
||||
export const ChatMessageBody = React.memo(
|
||||
({ message }: ChatMessageBodyProps) => {
|
||||
const formattedMessage = useMemo(() => {
|
||||
return formatChatMessageLinks(message)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
whiteSpace: 'pre-wrap',
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{formattedMessage}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatMessageBody.displayName = 'ChatMessageBody'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ChatRow, chatStore } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
const StyledContainer = styled('span', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
||||
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
||||
|
||||
export const ChatMessageMetadata = React.memo(
|
||||
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
||||
const time = new Date(timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
|
||||
const { names } = useSnapshot(chatStore)
|
||||
|
||||
const currentDisplayName = useMemo(() => {
|
||||
if (identity) return names[identity] ?? identity
|
||||
}, [names, identity])
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Text bold={true} variant="sm">
|
||||
{currentDisplayName}
|
||||
</Text>
|
||||
<Text variant="smNote" wrap="no">
|
||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListLayout,
|
||||
Virtualizer,
|
||||
} from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChatMessage } from './ChatMessage.tsx'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { ChatRow, chatStore } from '@/stores/chat'
|
||||
|
||||
// Estimated height of a chat entry in px. ListLayout measures the real
|
||||
// rendered height of each row; this value is only used to size the
|
||||
// scrollbar before rows have been measured.
|
||||
const ESTIMATED_ROW_HEIGHT = 56
|
||||
const BOTTOM_THRESHOLD_PX = 32
|
||||
|
||||
export const ChatMessages = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
const { rows: items } = useSnapshot(chatStore)
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const stick = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
const pin = () => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
const onScroll = () => {
|
||||
stick.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
||||
}
|
||||
const ro = new ResizeObserver(pin)
|
||||
// Sizer div = ListLayout's content size. Fires on append, and again each
|
||||
// time an estimated row height is replaced by a measured one.
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
||||
ro.observe(el)
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
pin()
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<ListBox
|
||||
ref={listRef}
|
||||
aria-label={t('messagesLabel', 'Chat messages')}
|
||||
items={items}
|
||||
selectionMode="none"
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{(item: ChatRow) => (
|
||||
<ListBoxItem style={{ width: '100%' }}>
|
||||
<ChatMessage item={item} />
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
||||
import { ref } from 'valtio'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||
import type { ChatMessage } from '@livekit/components-core'
|
||||
import {
|
||||
LocalParticipant,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from 'livekit-client'
|
||||
|
||||
export const ChatProvider = () => {
|
||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
const { isChatOpen } = useSidePanel()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
resetChatStore()
|
||||
}, [])
|
||||
|
||||
// Tigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||
// (see https://github.com/livekit/components-js/issues/1158)
|
||||
// Remove this once we refactor chat to use the new text stream approach
|
||||
const latestMessage = chatMessages.slice(-1)[0]
|
||||
if (!latestMessage) return
|
||||
const from = latestMessage.from as
|
||||
| RemoteParticipant
|
||||
| LocalParticipant
|
||||
| undefined
|
||||
|
||||
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
||||
}, [chatMessages, room])
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
||||
appendRow(chatMessages[i])
|
||||
}
|
||||
}, [chatMessages])
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.send = ref(send)
|
||||
}, [send])
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.isSending = isSending
|
||||
}, [isSending])
|
||||
|
||||
// Set the unread messages count
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0) return
|
||||
const last = chatMessages[chatMessages.length - 1]
|
||||
if (isChatOpen) {
|
||||
lastReadMsgAt.current = last.timestamp
|
||||
chatStore.unreadMessages = 0
|
||||
return
|
||||
}
|
||||
chatStore.unreadMessages = chatMessages.filter(
|
||||
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
||||
).length
|
||||
}, [chatMessages, isChatOpen])
|
||||
|
||||
// Listen to participant name changes
|
||||
useEffect(() => {
|
||||
const setName = (p: Participant) => {
|
||||
chatStore.names[p.identity] = p.name || p.identity
|
||||
}
|
||||
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
||||
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||
|
||||
type ChatSubmitButtonProps = {
|
||||
handleSubmit: () => Promise<void>
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export const ChatSubmitButton = React.memo(
|
||||
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
return (
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="sm"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t('button.label')}
|
||||
>
|
||||
<RiSendPlane2Fill />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
||||
@@ -0,0 +1,92 @@
|
||||
import { TextArea } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
chatStore,
|
||||
clearTextAreaValue,
|
||||
persistTextAreaValue,
|
||||
} from '@/stores/chat'
|
||||
import { ChatSubmitButton } from './ChatSubmitButton'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
margin: '0.75rem 0 1.5rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: 4,
|
||||
},
|
||||
})
|
||||
|
||||
export const ChatTextArea = () => {
|
||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.focus({ preventScroll: true })
|
||||
const end = el.value.length
|
||||
el.setSelectionRange(end, end)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = chatStore.textAreaValue
|
||||
if (!send || !text) return
|
||||
await send(text)
|
||||
inputRef?.current?.focus({ preventScroll: true })
|
||||
clearTextAreaValue()
|
||||
}, [send, inputRef])
|
||||
|
||||
const isDisabled = !textAreaValue.trim() || isSending
|
||||
|
||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||
return
|
||||
e.preventDefault()
|
||||
await handleSubmit()
|
||||
}
|
||||
|
||||
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
value={textAreaValue}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={(e) => {
|
||||
persistTextAreaValue(e.target.value)
|
||||
}}
|
||||
fieldSizing={'content'}
|
||||
style={{
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
maxHeight: '240px',
|
||||
minHeight: `34px`,
|
||||
lineHeight: 1.25,
|
||||
padding: '7px 10px',
|
||||
}}
|
||||
placeholderStyle="strong"
|
||||
spellCheck={false}
|
||||
maxLength={2000}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
aria-label={t('textArea.label')}
|
||||
/>
|
||||
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { loadUserChoices } from '@livekit/components-core'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = loadUserChoices()
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
||||
const ASPECT_RATIO = 16 / 10
|
||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||
|
||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||
|
||||
interface CarouselLayoutState {
|
||||
orientation: CarouselOrientation
|
||||
maxVisibleTiles: number
|
||||
}
|
||||
|
||||
interface CarouselLayoutObserverProps {
|
||||
asideEl: React.RefObject<HTMLDivElement>
|
||||
orientation?: CarouselOrientation
|
||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||
}
|
||||
|
||||
const CarouselLayoutObserver = ({
|
||||
orientation,
|
||||
asideEl,
|
||||
onLayoutChange,
|
||||
}: CarouselLayoutObserverProps) => {
|
||||
const { width, height } = useSize(asideEl)
|
||||
|
||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||
// container size hovers around a breakpoint. A ref (not state) because
|
||||
// updating it must not trigger a re-render.
|
||||
const prevTilesRef = React.useRef(0)
|
||||
|
||||
const carouselOrientation: CarouselOrientation =
|
||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
|
||||
const availableSpan =
|
||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||
|
||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||
|
||||
let maxVisibleTiles: number
|
||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||
// Within the dead zone: keep the previous count.
|
||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||
} else {
|
||||
maxVisibleTiles = Math.round(tilesThatFit)
|
||||
prevTilesRef.current = tilesThatFit
|
||||
}
|
||||
|
||||
// Apply cosmetic layout output straight to the DOM.
|
||||
useLayoutEffect(() => {
|
||||
const el = asideEl.current
|
||||
if (!el) return
|
||||
el.dataset.lkOrientation = carouselOrientation
|
||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||
|
||||
// Report upward only what the parent actually needs for
|
||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||
useEffect(() => {
|
||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
||||
...props
|
||||
}: CarouselLayoutProps) {
|
||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||
const { width, height } = useSize(asideEl)
|
||||
const carouselOrientation = orientation
|
||||
? orientation
|
||||
: height >= width
|
||||
? 'vertical'
|
||||
: 'horizontal'
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||
orientation: orientation ?? 'vertical',
|
||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||
})
|
||||
|
||||
const tilesThatFit =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
// Stable callback + identity check: the parent only re-renders when the
|
||||
// derived layout genuinely changed, not on every resize tick.
|
||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||
setLayout((prev) =>
|
||||
prev.orientation === next.orientation &&
|
||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}, [])
|
||||
|
||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||
maxVisibleTiles = Math.round(prevTiles)
|
||||
} else if (prevTiles !== tilesThatFit) {
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||
asideEl.current.style.setProperty(
|
||||
'--lk-max-visible-tiles',
|
||||
maxVisibleTiles.toString()
|
||||
)
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
<>
|
||||
<CarouselLayoutObserver
|
||||
asideEl={asideEl}
|
||||
orientation={orientation}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
/>
|
||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||
<aside
|
||||
key={layout.orientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ParticipantTile } from './ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||
|
||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||
@@ -10,6 +10,36 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
import { PaginationControl } from './PaginationControl'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
|
||||
interface GridLayoutObserverProps {
|
||||
gridEl: React.RefObject<HTMLDivElement>
|
||||
trackCount: number
|
||||
onMaxTilesChange: (maxTiles: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless component that runs the layout calculation in isolation and
|
||||
* reports the resulting tile capacity upward.
|
||||
*
|
||||
* `useGridLayout` re-renders its host on every layout recalculation
|
||||
* (e.g. container resizes). Rendering it in a null child means only this
|
||||
* component churns; the parent `GridLayout` re-renders solely when
|
||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||
*/
|
||||
const GridLayoutObserver = ({
|
||||
gridEl,
|
||||
trackCount,
|
||||
onMaxTilesChange,
|
||||
}: GridLayoutObserverProps) => {
|
||||
const { layout } = useGridLayout(gridEl, trackCount)
|
||||
useEffect(() => {
|
||||
onMaxTilesChange(layout.maxTiles)
|
||||
}, [onMaxTilesChange, layout.maxTiles])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
||||
* @public
|
||||
*/
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
const gridEl = useRef<HTMLDivElement>(null)
|
||||
const [maxTiles, setMaxTiles] = useState(1)
|
||||
|
||||
const elementProps = React.useMemo(
|
||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||
[props]
|
||||
)
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
const pagination = usePagination(maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
@@ -57,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
data-lk-pagination={pagination.totalPageCount > 1}
|
||||
{...elementProps}
|
||||
>
|
||||
<GridLayoutObserver
|
||||
gridEl={gridEl}
|
||||
trackCount={tracks.length}
|
||||
onMaxTilesChange={setMaxTiles}
|
||||
/>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > layout.maxTiles && (
|
||||
{tracks.length > maxTiles && (
|
||||
<>
|
||||
<PaginationIndicator
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||
|
||||
export const PinAnnouncer = () => {
|
||||
usePinAnnouncements()
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
log,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const StageLayout = () => {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ updateOnlyOn: [], onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTracks = tracks
|
||||
.filter(isTrackReference)
|
||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
|
||||
const carouselTracks = tracks.filter(
|
||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||
)
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
useEffect(() => {
|
||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||
if (
|
||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||
lastAutoFocusedScreenShareTrack.current === null
|
||||
) {
|
||||
log.debug('Auto set screen share focus:', {
|
||||
newScreenShareTrack: screenShareTracks[0],
|
||||
})
|
||||
setPinnedTrack(screenShareTracks[0])
|
||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||
} else if (
|
||||
lastAutoFocusedScreenShareTrack.current &&
|
||||
!screenShareTracks.some(
|
||||
(track) =>
|
||||
track.publication.trackSid ===
|
||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||
)
|
||||
) {
|
||||
log.debug('Auto clearing screen share focus.')
|
||||
clearPinnedTrack()
|
||||
lastAutoFocusedScreenShareTrack.current = null
|
||||
}
|
||||
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
||||
const updatedFocusTrack = tracks.find(
|
||||
(tr) =>
|
||||
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
||||
tr.source === pinnedTrackRef.source
|
||||
)
|
||||
if (
|
||||
updatedFocusTrack !== pinnedTrackRef &&
|
||||
isTrackReference(updatedFocusTrack)
|
||||
) {
|
||||
setPinnedTrack(updatedFocusTrack)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
screenShareTracks
|
||||
.map(
|
||||
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
||||
)
|
||||
.join(),
|
||||
pinnedTrackRef?.publication?.trackSid,
|
||||
tracks,
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
return (
|
||||
<>
|
||||
{!pinnedTrackRef ? (
|
||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
<FocusLayout trackRef={pinnedTrackRef} />
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const usePinAnnouncements = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const lastPinnedIdentityRef = useRef<string | null>(null)
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
if (!participant) return tRooms('participants.unknown')
|
||||
return participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
},
|
||||
[tRooms]
|
||||
)
|
||||
|
||||
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
||||
|
||||
useEffect(() => {
|
||||
// 1. unpin
|
||||
if (!pinnedIdentity) {
|
||||
const lastIdentity = lastPinnedIdentityRef.current
|
||||
if (!lastIdentity) return
|
||||
|
||||
const lastParticipant =
|
||||
room.localParticipant.identity === lastIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(lastIdentity)
|
||||
|
||||
announce(
|
||||
lastParticipant?.isLocal
|
||||
? t('self.unpin')
|
||||
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
||||
)
|
||||
|
||||
lastPinnedIdentityRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// 2. same pin → do nothing
|
||||
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
||||
|
||||
// 3. new pin
|
||||
const participant =
|
||||
room.localParticipant.identity === pinnedIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(pinnedIdentity)
|
||||
|
||||
lastPinnedIdentityRef.current = pinnedIdentity
|
||||
|
||||
announce(
|
||||
participant?.isLocal
|
||||
? t('self.pin')
|
||||
: t('pin', { name: getAnnouncementName(participant) })
|
||||
)
|
||||
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useReducer, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Tripwire for speaker promotion.
|
||||
*
|
||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||
* re-render of the host component only when an active speaker has none of
|
||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||
* state and performs the actual swap.
|
||||
*
|
||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||
*
|
||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useSpeakerPromotionTrigger(
|
||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||
) {
|
||||
const room = useRoomContext()
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||
|
||||
// Refs so the listener reads current values without re-subscribing
|
||||
// and without itself being a render dependency.
|
||||
const tilesRef = useRef(sortedTiles)
|
||||
tilesRef.current = sortedTiles
|
||||
|
||||
useEffect(() => {
|
||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||
const tiles = tilesRef.current
|
||||
const hiddenSpeakerExists = speakers.some(
|
||||
(speaker) =>
|
||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||
)
|
||||
if (hiddenSpeakerExists) {
|
||||
forceRender()
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
}
|
||||
}, [room])
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useRef } from 'react'
|
||||
import type { ToastState } from '@react-stately/toast'
|
||||
import type { ToastState } from 'react-stately'
|
||||
import type { ToastData } from './ToastProvider'
|
||||
import type { QueuedToast } from '@react-stately/toast'
|
||||
import type { QueuedToast } from 'react-stately'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { StyledToast } from './StyledToast'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import type { ToastProps } from './Toast'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -28,7 +28,6 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
state,
|
||||
ref
|
||||
)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
@@ -38,18 +37,13 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const pinParticipant = () => {
|
||||
layoutContext?.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<ClickableToast
|
||||
ref={ref}
|
||||
onPress={(e) => {
|
||||
pinParticipant()
|
||||
setPinnedTrack(trackReference)
|
||||
closeButtonProps.onPress?.(e)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
||||
import { ToastQueue, useToastQueue } from 'react-stately'
|
||||
import { ToastRegion } from './ToastRegion'
|
||||
import { Participant } from 'livekit-client'
|
||||
import type { NotificationType } from '../NotificationType'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useToast } from 'react-aria'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
||||
import type { QueuedToast, ToastState } from '@react-stately/toast'
|
||||
import { AriaToastRegionProps, useToastRegion } from 'react-aria'
|
||||
import type { QueuedToast, ToastState } from 'react-stately'
|
||||
import { Toast } from './Toast'
|
||||
import { useRef } from 'react'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
|
||||
+6
-5
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { LocalParticipant } from 'livekit-client'
|
||||
|
||||
export const FullScreenShareWarning = ({
|
||||
trackReference,
|
||||
@@ -17,7 +17,6 @@ export const FullScreenShareWarning = ({
|
||||
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||
|
||||
const isFullScreenSharing = useMemo(() => {
|
||||
@@ -57,8 +56,10 @@ export const FullScreenShareWarning = ({
|
||||
screenSharePreferences.enabled && isFullScreenSharing
|
||||
|
||||
const handleStopScreenShare = async () => {
|
||||
if (!localParticipant.isScreenShareEnabled) return
|
||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||
const participant = trackReference.participant
|
||||
if (!(participant instanceof LocalParticipant)) return
|
||||
if (!participant.isScreenShareEnabled) return
|
||||
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
||||
}
|
||||
|
||||
const handleDismissWarning = useCallback(() => {
|
||||
+9
-7
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const Hint = styled('div', {
|
||||
@@ -25,15 +25,17 @@ const Hint = styled('div', {
|
||||
})
|
||||
|
||||
export interface KeyboardShortcutHintProps {
|
||||
children: ReactNode
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||
* typically when an element receives keyboard focus.
|
||||
*/
|
||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return <Hint>{children}</Hint>
|
||||
}
|
||||
export const KeyboardShortcutHint = React.memo(
|
||||
({ hint }: KeyboardShortcutHintProps) => {
|
||||
return <Hint>{hint}</Hint>
|
||||
}
|
||||
)
|
||||
|
||||
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ScreenShareIcon,
|
||||
useIsEncrypted,
|
||||
} from '@livekit/components-react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||
|
||||
export const ParticipantMetadata = ({
|
||||
displayedName,
|
||||
participant,
|
||||
isScreenShare,
|
||||
}: {
|
||||
displayedName: string
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const isEncrypted = useIsEncrypted(participant)
|
||||
|
||||
return (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
||||
<RaisedHandMetadataWrapper
|
||||
participant={participant}
|
||||
enabled={!isScreenShare}
|
||||
>
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
displayedName={displayedName}
|
||||
isScreenShare={isScreenShare}
|
||||
/>
|
||||
</div>
|
||||
</RaisedHandMetadataWrapper>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { CSSProperties } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const participantNameStyles: CSSProperties = {
|
||||
paddingBottom: '0.1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const participantNameScreenShareStyles: CSSProperties = {
|
||||
...participantNameStyles,
|
||||
marginLeft: '0.4rem',
|
||||
}
|
||||
|
||||
export const ParticipantName = React.memo(
|
||||
({
|
||||
displayedName,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
displayedName?: string
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantName.displayName = 'ParticipantName'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
containerType: 'size',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledAvatarWrapper = styled('div', {
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
aspectRatio: '1 / 1',
|
||||
width: 'min(90cqmin, 160px)',
|
||||
fontSize: 'min(27cqmin, 48px)',
|
||||
'[data-lk-speaking="true"] &': {
|
||||
animation: 'pulse 1s infinite',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantPlaceholderProps = {
|
||||
color: string
|
||||
displayedNamed: string
|
||||
}
|
||||
|
||||
export const ParticipantPlaceholder = React.memo(
|
||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(color),
|
||||
[color]
|
||||
)
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<StyledAvatarWrapper>
|
||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||
</StyledAvatarWrapper>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||
+34
-104
@@ -1,39 +1,32 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
VideoTrack,
|
||||
TrackRefContext,
|
||||
ParticipantContextIfNeeded,
|
||||
useParticipantInfo,
|
||||
} from '@livekit/components-react'
|
||||
import React from 'react'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
isTrackReferencePinned,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -52,6 +45,7 @@ export function TrackRefContextIfNeeded(
|
||||
|
||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||
disableMetadata?: boolean
|
||||
disableTileControls?: boolean
|
||||
}
|
||||
|
||||
export const ParticipantTile: (
|
||||
@@ -66,21 +60,18 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
disableSpeakingIndicator,
|
||||
disableMetadata,
|
||||
disableTileControls,
|
||||
...htmlProps
|
||||
}: ParticipantTileExtendedProps,
|
||||
ref
|
||||
) {
|
||||
const trackReference = useEnsureTrackRef(trackRef)
|
||||
|
||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||
htmlProps,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
|
||||
const handleSubscribe = React.useCallback(
|
||||
@@ -88,28 +79,25 @@ export const ParticipantTile: (
|
||||
if (
|
||||
trackReference.source &&
|
||||
!subscribed &&
|
||||
layoutContext &&
|
||||
layoutContext.pin.dispatch &&
|
||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||
layoutStore.pinnedTrackRef &&
|
||||
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
||||
) {
|
||||
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
||||
clearPinnedTrack()
|
||||
}
|
||||
},
|
||||
[trackReference, layoutContext]
|
||||
[trackReference]
|
||||
)
|
||||
|
||||
const { isHandRaised } = useRaisedHand({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const participantColor = getParticipantColor(trackReference.participant)
|
||||
|
||||
const { identity, name } = useParticipantInfo({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
const participantName = name || identity || 'Unknown'
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const interactiveProps = {
|
||||
@@ -136,7 +124,9 @@ export const ParticipantTile: (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
{trackReference.participant.isLocal && (
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
)}
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
@@ -158,80 +148,20 @@ export const ParticipantTile: (
|
||||
)}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
participant={trackReference.participant}
|
||||
color={participantColor}
|
||||
displayedNamed={participantName}
|
||||
/>
|
||||
</div>
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare
|
||||
? firstInQueue
|
||||
? '#fde047'
|
||||
: 'white'
|
||||
: undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
<ParticipantMetadata
|
||||
displayedName={participantName}
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
@@ -239,13 +169,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>
|
||||
{t('toolbarHint', {
|
||||
<KeyboardShortcutHint
|
||||
hint={t('toolbarHint', {
|
||||
shortcut: formatShortcutLabel(
|
||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||
),
|
||||
})}
|
||||
</KeyboardShortcutHint>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
||||
import {
|
||||
useRaisedHand,
|
||||
useRaisedHandPosition,
|
||||
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
|
||||
const PositionInQueue = React.memo(
|
||||
({ positionInQueue }: { positionInQueue?: number }) => {
|
||||
if (!positionInQueue) return
|
||||
return (
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PositionInQueue.displayName = 'PositionInQueue'
|
||||
|
||||
const RaisedHandActiveItem = ({
|
||||
participant,
|
||||
targetRef,
|
||||
}: {
|
||||
participant: Participant
|
||||
targetRef: RefObject<HTMLElement | null>
|
||||
}) => {
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant,
|
||||
})
|
||||
useEffect(() => {
|
||||
const el = targetRef.current
|
||||
if (!el) return
|
||||
|
||||
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
||||
el.style.color = 'black'
|
||||
|
||||
return () => {
|
||||
el.style.backgroundColor = ''
|
||||
el.style.color = ''
|
||||
}
|
||||
}, [targetRef, firstInQueue])
|
||||
|
||||
return <PositionInQueue positionInQueue={positionInQueue} />
|
||||
}
|
||||
|
||||
export const RaisedHandMetadataWrapper = ({
|
||||
participant,
|
||||
enabled = true,
|
||||
children,
|
||||
}: {
|
||||
participant: Participant
|
||||
enabled?: boolean
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const { isHandRaised } = useRaisedHand({ participant })
|
||||
const showHand = isHandRaised && enabled
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
transition: 'background 200ms ease',
|
||||
}}
|
||||
>
|
||||
{showHand && (
|
||||
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
|
||||
export const EffectsButton = React.memo(() => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||
>
|
||||
<RiImageCircleAiFill />
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
EffectsButton.displayName = 'EffectsButton'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user