Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine de50aeb4fe wip hint if the user is authenticated or not in attributes 2026-05-17 23:44:49 +02:00
81 changed files with 1105 additions and 4671 deletions
+1
View File
@@ -305,6 +305,7 @@ 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"
+1 -5
View File
@@ -10,17 +10,13 @@ and this project adheres to
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
## [1.16.0] - 2026-05-13
+1 -2
View File
@@ -64,9 +64,8 @@ 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/v2/async-jobs/transcribe/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
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).
+31 -293
View File
@@ -36,7 +36,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
"webpack-dev-server": "5.2.1"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -4319,195 +4319,6 @@
"node": ">= 4.0.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-rsa": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pfx": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@peculiar/x509/node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4722,6 +4533,16 @@
"form-data": "^4.0.4"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/office-js": {
"version": "1.0.582",
"resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz",
@@ -5778,21 +5599,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -6264,16 +6070,6 @@
"node": ">= 0.8"
}
},
"node_modules/bytestreamjs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -12844,24 +12640,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkijs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
"asn1js": "^3.0.6",
"bytestreamjs": "^2.0.1",
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -13045,26 +12823,6 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -13793,17 +13551,17 @@
"license": "MIT"
},
"node_modules/selfsigned": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz",
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/x509": "^1.14.2",
"pkijs": "^3.3.3"
"@types/node-forge": "^1.3.0",
"node-forge": "^1"
},
"engines": {
"node": ">=18"
"node": ">=10"
}
},
"node_modules/semver": {
@@ -15037,26 +14795,6 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -15678,15 +15416,15 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
"integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.25",
"@types/express": "^4.17.21",
"@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5",
@@ -15696,17 +15434,17 @@
"bonjour-service": "^1.2.1",
"chokidar": "^3.6.0",
"colorette": "^2.0.10",
"compression": "^1.8.1",
"compression": "^1.7.4",
"connect-history-api-fallback": "^2.0.0",
"express": "^4.22.1",
"express": "^4.21.2",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.9",
"http-proxy-middleware": "^2.0.7",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",
"p-retry": "^6.2.0",
"schema-utils": "^4.2.0",
"selfsigned": "^5.5.0",
"selfsigned": "^2.4.1",
"serve-index": "^1.9.1",
"sockjs": "^0.3.24",
"spdy": "^4.0.2",
@@ -15762,9 +15500,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15791,9 +15529,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -53,7 +53,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
"webpack-dev-server": "5.2.1"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
+1 -45
View File
@@ -39,10 +39,7 @@ from core import 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 (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -998,47 +995,6 @@ class RecordingViewSet(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
)
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)
data = request.data
if not data.get("job_id"):
raise drf_exceptions.ValidationError(detail="No job_id provided")
job_id = 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)
recording = None
changed = False
if recording and data.get("type") == "transcript":
if data.get("status") == "success":
logger.info("External process received for recording %s", job_id)
recording.status = (
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
)
changed = True
if data.get("status") == "failure":
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
changed = True
if changed and recording:
recording.save()
else:
logger.info("No changes to save for external process id %s", job_id)
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -1,23 +0,0 @@
# Generated by Django 5.2.14 on 2026-05-26 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_rename_active_application_is_active'),
]
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),
),
]
-17
View File
@@ -60,11 +60,6 @@ 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):
@@ -78,8 +73,6 @@ 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,14 +591,6 @@ 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"
@@ -661,8 +646,6 @@ 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, username: str = "storage_event_user") -> None:
def __init__(self) -> None:
self.pk = None
self.username = username
self.username = "storage_event_user"
self.is_active = True
@property
@@ -91,29 +91,3 @@ class StorageEventAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'"
class RecordingProcessWebhookAuthentication(BaseAuthentication):
"""
Custom authentication class for recording process webhook requests.
Validates the API key in the Authorization header.
"""
def authenticate(self, request):
"""
Authenticate the request and return a two-tuple of (user, token).
"""
logger.info("Authentificating")
authorization_header: str = request.headers.get("Authorization") or ""
if not secrets.compare_digest(
authorization_header.removeprefix("Bearer "),
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN,
):
logger.warning(
"Authentication failed: Bad Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed()
return MachineUser("external_process_user"), None
@@ -1,10 +1,8 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import json
import logging
import smtplib
import warnings
from datetime import datetime, timezone
from django.conf import settings
@@ -19,7 +17,6 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.utils import generate_download_s3_file_url
logger = logging.getLogger(__name__)
@@ -218,45 +215,18 @@ class NotificationService:
)(recording.worker_id)
payload = {
# Legacy V1 params to avoid a breaking change
"owner_id": str(owner_access.user.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"owner_timezone": str(owner_access.user.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
# V2 params
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_file_url(
recording.key, expires_in=60 * 60 * 24, override_domain=False
),
"language": recording.options.get("language", "fr"),
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"title": "TODO",
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
# For now the feature flag logic is handled on summary side
"auto_create_summary": True,
},
"metadata": (
{
"cloud_storage_url": generate_download_s3_file_url(
metadata_filename,
expires_in=60 * 60 * 24,
override_domain=False,
),
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
}
if (started_at and ended_at)
else None
),
}
headers = {
@@ -272,31 +242,6 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
is_v2_implementation = False
try:
response_json = response.json()
# We do not require a job_id to avoid a breaking change
if job_id := response_json.get("job_id"):
recording.external_process_id = job_id
recording.save()
is_v2_implementation = True
except json.JSONDecodeError:
pass
if not is_v2_implementation:
warnings.warn(
"You are likely using your own implementation for the summary / "
"transcribe service."
"We have released a new version and API contract for that service, "
"we recommend checking it out"
# pylint: disable=line-too-long
"https://github.com/suitenumerique/meet/blob/19c2a378e7e66652afbfa3e749badd697e3917ac/src/summary/summary/api/route/tasks_v2.py "
"and mimicking it's behavior. We will remove the legacy "
"handling in a future version.",
DeprecationWarning,
stacklevel=2,
)
except requests.RequestException as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
@@ -1,134 +0,0 @@
"""
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 == 403
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 == 403
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 "No job_id provided" in str(response.json())
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
+5 -36
View File
@@ -121,7 +121,11 @@ def generate_token(
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
{
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": not user.is_anonymous,
}
)
)
@@ -455,38 +459,3 @@ def generate_upload_policy(file):
)
return policy
def generate_download_s3_file_url(
key, *, expires_in: int, override_domain: bool = True
):
"""
Generate a S3 signed download url for a given key.
"""
# This settings 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
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
return s3_client.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": default_storage.bucket_name, "Key": key},
ExpiresIn=expires_in,
)
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -88,17 +88,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
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:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -146,59 +146,51 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "ID"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "erstellt am"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -206,11 +198,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -218,55 +210,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:184
#: core/models.py:177
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:190
#: core/models.py:183
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:193
#: core/models.py:186
msgid "device"
msgstr "Gerät"
#: core/models.py:195
#: core/models.py:188
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:198
#: core/models.py:191
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "aktiv"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -274,66 +266,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "Benutzer"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "Benutzer"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:411
#: core/models.py:403
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:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Raum"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Räume"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -342,149 +334,141 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:760
#: core/models.py:742
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:763
#: core/models.py:745
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:788
#: core/models.py:770
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:799
#: core/models.py:781
msgid "Application"
msgstr "Anwendung"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:858
#: core/models.py:840
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Bereit"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "Datei"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Dateien"
#: core/models.py:1018
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1028
#: core/models.py:1010
#, 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."
@@ -492,7 +476,7 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -592,9 +576,7 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: core/templates/mail/html/screen_recording.html:209
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -88,15 +88,15 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
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:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
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,59 +144,51 @@ msgstr "Failed to Stop"
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "created on"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "updated on"
#: core/models.py:131
#: core/models.py:124
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:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -204,11 +196,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -216,55 +208,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:184
#: core/models.py:177
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:190
#: core/models.py:183
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:193
#: core/models.py:186
msgid "device"
msgstr "device"
#: core/models.py:195
#: core/models.py:188
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:198
#: core/models.py:191
msgid "staff status"
msgstr "staff status"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "active"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -272,63 +264,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "user"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "users"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Resource"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Resources"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:411
#: core/models.py:403
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:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Room"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -336,159 +328,151 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Recording"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:760
#: core/models.py:742
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:763
#: core/models.py:745
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Application name"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:788
#: core/models.py:770
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:799
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:858
#: core/models.py:840
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Ready"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Background image"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "title"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "File"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Files"
#: core/models.py:1018
#: core/models.py:1000
#, 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:1028
#: core/models.py:1010
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:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -588,9 +572,7 @@ msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: core/templates/mail/html/screen_recording.html:209
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -89,17 +89,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
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:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
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,61 +147,53 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "créé le"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:131
#: core/models.py:124
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:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -209,11 +201,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -221,55 +213,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:168
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:184
#: core/models.py:177
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:190
#: core/models.py:183
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:193
#: core/models.py:186
msgid "device"
msgstr "appareil"
#: core/models.py:195
#: core/models.py:188
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:198
#: core/models.py:191
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "actif"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -277,65 +269,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "utilisateur"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "utilisateurs"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Ressources"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:345
#: core/models.py:338
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:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:411
#: core/models.py:403
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:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Salle"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Salles"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -343,158 +335,150 @@ 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:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:743
#: core/models.py:725
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:760
#: core/models.py:742
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:763
#: core/models.py:745
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:788
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:799
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domaine"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:858
#: core/models.py:840
msgid "Pending"
msgstr "En attente"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Prêt"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "Titre"
#: core/models.py:908
#: core/models.py:890
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:913
#: core/models.py:895
msgid "File"
msgstr "Fichier"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1018
#: core/models.py:1000
#, 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:1028
#: core/models.py:1010
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:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -594,9 +578,7 @@ 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
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -88,16 +88,16 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
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:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -145,59 +145,51 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:131
#: core/models.py:124
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:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -205,11 +197,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -217,55 +209,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:184
#: core/models.py:177
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:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "apparaat"
#: core/models.py:195
#: core/models.py:188
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:198
#: core/models.py:191
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "actief"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -273,64 +265,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "gebruiker"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "gebruikers"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Bron"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:411
#: core/models.py:403
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:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Ruimte"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -338,148 +330,140 @@ 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:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Opname"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:760
#: core/models.py:742
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:763
#: core/models.py:745
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:788
#: core/models.py:770
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:799
#: core/models.py:781
msgid "Application"
msgstr "Applicatie"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domein"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:858
#: core/models.py:840
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Klaar"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "Bestand"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1018
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1028
#: core/models.py:1010
#, 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."
@@ -487,7 +471,7 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -587,9 +571,7 @@ msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: core/templates/mail/html/screen_recording.html:209
-3
View File
@@ -744,9 +744,6 @@ class Base(Configuration):
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
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
-1
View File
@@ -279,7 +279,6 @@ const config: Config = {
'room-side-panel-margin': { value: '1.5rem' },
'room-control-bar': { value: '80px' },
'room-reaction-toolbar-height': { value: '42px' },
'tooltip-spacing': { value: '8px' },
},
spacing,
}),
-121
View File
@@ -1,121 +0,0 @@
<svg width="102" height="72" viewBox="0 0 102 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13227_1168)">
<g filter="url(#filter0_d_13227_1168)">
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#969EB0"/>
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#969EB0" stroke-width="1.1926"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#181B24" stroke-opacity="0.6" stroke-width="1.1926"/>
<g filter="url(#filter1_d_13227_1168)">
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter2_d_13227_1168)">
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter3_d_13227_1168)">
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter4_d_13227_1168)">
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
</g>
<g filter="url(#filter5_d_13227_1168)">
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#7E98FF"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="url(#paint0_linear_13227_1168)" fill-opacity="0.05"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#7E98FF" stroke-width="1.1926"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#181B24" stroke-opacity="0.45" stroke-width="1.1926"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#FF706E"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#181B24" fill-opacity="0.45"/>
</g>
<g filter="url(#filter6_d_13227_1168)">
<path d="M50.3653 53.4428C51.0628 53.4428 51.6941 53.2682 52.2593 52.9191C52.8304 52.576 53.2844 52.1135 53.6211 51.5316C53.9638 50.9558 54.1352 50.3156 54.1352 49.6112C54.1352 48.9006 53.9638 48.2543 53.6211 47.6724C53.2844 47.0965 52.8304 46.634 52.2593 46.2849C51.6941 45.9418 51.0628 45.7703 50.3653 45.7703H47.344C46.1836 45.7703 45.1555 45.6539 44.2596 45.4211C43.3637 45.1884 42.5731 44.7871 41.8876 44.2174C41.2022 43.6539 40.598 42.8698 40.0749 41.8652C39.9185 41.5711 39.7412 41.3843 39.5428 41.3046C39.3504 41.225 39.158 41.1852 38.9656 41.1852C38.7251 41.1852 38.5086 41.2924 38.3162 41.5068C38.1298 41.7151 38.0366 42.0183 38.0366 42.4165C38.0366 44.1133 38.217 45.6386 38.5777 46.9924C38.9445 48.3523 39.5037 49.5131 40.2552 50.4749C41.0128 51.4366 41.9778 52.1717 43.1503 52.6801C44.3287 53.1886 45.7266 53.4428 47.344 53.4428H50.3653ZM47.6056 42.2603V56.9161C47.6056 57.2224 47.7048 57.4858 47.9032 57.7063C48.1076 57.9268 48.3692 58.0371 48.6878 58.0371C48.9043 58.0371 49.0997 57.985 49.274 57.8809C49.4544 57.7829 49.6649 57.6175 49.9054 57.3847L57.0212 50.6035C57.1955 50.4381 57.3158 50.2697 57.3819 50.0981C57.4481 49.9266 57.4811 49.7643 57.4811 49.6112C57.4811 49.4641 57.4481 49.3049 57.3819 49.1333C57.3158 48.9618 57.1955 48.7934 57.0212 48.628L49.9054 41.7825C49.6889 41.5742 49.4815 41.4241 49.2831 41.3322C49.0907 41.2342 48.8862 41.1852 48.6698 41.1852C48.3631 41.1852 48.1076 41.2863 47.9032 41.4884C47.7048 41.6906 47.6056 41.9478 47.6056 42.2603Z" fill="#969EB0"/>
</g>
</g>
<defs>
<filter id="filter0_d_13227_1168" x="7.82784" y="4.69151" width="68.1964" height="57.826" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter1_d_13227_1168" x="17.3569" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter2_d_13227_1168" x="17.3569" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter3_d_13227_1168" x="39.394" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter4_d_13227_1168" x="39.394" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter5_d_13227_1168" x="44.1242" y="30.6175" width="50.0479" height="44.8629" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter6_d_13227_1168" x="29.8645" y="37.0992" width="35.7887" height="33.1961" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<linearGradient id="paint0_linear_13227_1168" x1="69.1483" y1="34.7036" x2="69.1483" y2="63.2222" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6F8F9" stop-opacity="0.975"/>
<stop offset="1" stop-color="#F6F8F9" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_13227_1168">
<rect width="102" height="72" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 9.7 KiB

@@ -22,8 +22,6 @@ const controlBarRegion = cva({
},
})
export const CONTROL_BAR_REGION_ID = 'control-bar-region'
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
RecipeVariantProps<typeof controlBarRegion>
@@ -36,7 +34,6 @@ export function ControlBarRegion({
return (
<div
role="region"
id={CONTROL_BAR_REGION_ID}
aria-label={t('controls.region')}
className={controlBarRegion({ mobile })}
{...props}
@@ -3,17 +3,18 @@ import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { Div } from '@/primitives'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { toastQueue } from './components/ToastProvider'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
import { NotificationProvider } from './NotificationProvider'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -232,5 +233,10 @@ export const MainNotificationToast = () => {
// the 'notifications' namespace might not be loaded yet
useTranslation(['notifications'])
return <NotificationProvider />
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
</Div>
)
}
@@ -1,16 +0,0 @@
import { Div } from '@/primitives'
import { ToastProvider } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
export const NotificationProvider = ({
bottom = 0,
right = 5,
}: {
bottom?: number
right?: number
}) => (
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
</Div>
)
@@ -1,8 +0,0 @@
import { PictureInPicturePortal } from '@/features/pip/components/PictureInPicturePortal'
import { PipView } from '@/features/pip/components/PipView'
export const PictureInPictureConference = () => (
<PictureInPicturePortal>
<PipView />
</PictureInPicturePortal>
)
@@ -1,56 +0,0 @@
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { createPortal } from 'react-dom'
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useSnapshot } from 'valtio'
import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
}, [pipStoreSnap.window])
useEffect(() => {
return () => {
documentPictureInPictureStore.window?.close()
}
}, [])
if (!container) return null
return createPortal(
/**
* UNSAFE_PortalProvider is marked unsafe because overlays are normally
* portalled to `document.body` to avoid clipping, stacking, and accessibility
* issues. Redirecting them to another container can break those guarantees.
*
* We accept that risk here because the PiP window is a separate document with
* its own `body`. Rendering overlays into the main document would make them
* invisible in PiP, so we intentionally portal them into the PiP document root
* instead.
*/
<UNSAFE_PortalProvider getContainer={() => container}>
{/*React Aria computes overlay position based on the main window size, so*/}
{/*we must disable it to position overlays correctly across documents.*/}
<CrossDocumentOverlaysContext.Provider value={true}>
{children}
</CrossDocumentOverlaysContext.Provider>
</UNSAFE_PortalProvider>,
container
)
}
export const PictureInPicturePortal = ({
children,
}: {
children: React.ReactNode
}): React.ReactNode => {
const { isSupported } = usePictureInPicture()
if (!isSupported) return null
return <InternalPortal>{children}</InternalPortal>
}
@@ -1,116 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
import { usePipElementSize } from '../hooks/usePipElementSize'
import { useMemo, useRef } from 'react'
export const CollapsibleControls = {
HAND: 'hand',
SCREEN_SHARE: 'screenShare',
REACTIONS: 'reactions',
} as const
export type CollapsibleControl =
(typeof CollapsibleControls)[keyof typeof CollapsibleControls]
const COLLAPSE_ORDER: CollapsibleControl[] = [
CollapsibleControls.HAND,
CollapsibleControls.SCREEN_SHARE,
CollapsibleControls.REACTIONS,
]
const BUTTON_SLOT = 50
const ESSENTIAL_WIDTH = 260
const getHiddenControls = (
containerWidth: number,
showScreenShare: boolean
): Set<CollapsibleControl> => {
const hidden = new Set<CollapsibleControl>()
if (containerWidth <= 0) return hidden
const collapsible = showScreenShare
? COLLAPSE_ORDER
: COLLAPSE_ORDER.filter((c) => c !== CollapsibleControls.SCREEN_SHARE)
const available = containerWidth - ESSENTIAL_WIDTH
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
for (let i = 0; i < collapsible.length - maxVisible; i++) {
hidden.add(collapsible[i])
}
return hidden
}
export const PipControlBar = ({
showScreenShare,
}: {
showScreenShare: boolean
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width } = usePipElementSize(containerRef)
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const hidden = useMemo(
() => getHiddenControls(width, showScreenShare),
[width, showScreenShare]
)
return (
<PipControls
ref={containerRef}
id="pip-control-bar"
role="toolbar"
aria-label={t('controlBar')}
>
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{!hidden.has(CollapsibleControls.REACTIONS) && <ReactionsToggle />}
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
<ScreenShareToggle />
)}
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
<StartMediaButton />
<PipOptionsMenu overflowControls={hidden} />
<LeaveButton />
</PipControlsCenter>
</PipControls>
)
}
const PipControls = styled('div', {
base: {
flex: '0 0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '0.5rem',
padding: '1.125rem',
width: '100%',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
})
const PipControlsCenter = styled('div', {
base: {
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'center',
alignItems: 'center',
gap: '0.4rem',
flex: '1 1 auto',
},
})
@@ -1,46 +0,0 @@
import { useSnapshot } from 'valtio'
import { reactionsStore } from '@/stores/reactions'
import { useMemo, useRef } from 'react'
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
import { Reaction } from '@/features/reactions/types'
import { css } from '@/styled-system/css'
export const PipFloatingReactions = () => {
const { reactions } = useSnapshot(reactionsStore)
return (
<>
{reactions.map((reaction) => (
<PipFloatingReaction key={reaction.id} reaction={reaction} />
))}
</>
)
}
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
const containerRef = useRef<HTMLDivElement>(null)
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return (
<div
ref={containerRef}
className={css({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'hidden',
})}
>
<FloatingReaction
emoji={reaction.emoji}
speed={speed}
scale={scale}
name={reaction.participantName}
isLocal={reaction.isLocal}
/>
</div>
)
}
@@ -1,48 +0,0 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { Button, Text } from '@/primitives'
export const PipRoomPlaceholder = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture.placeholder',
})
const { close } = usePictureInPicture()
return (
<Container>
<img
src="/assets/pip.svg"
alt=""
width={300}
height={72}
aria-hidden="true"
style={{
marginBottom: '0.25rem',
}}
/>
<Text variant="body">{t('title')}</Text>
<Text variant="sm" style={{ maxWidth: '312px' }}>
{t('description')}
</Text>
<Button variant="primaryTextDark" onPress={close}>
{t('bringBack')}
</Button>
</Container>
)
}
const Container = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
gap: '0.5rem',
padding: '1.5rem',
textAlign: 'center',
color: 'white',
},
})
@@ -1,76 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { PipControlBar } from './PipControlBar'
import { PipFloatingReactions } from './PipFloatingReactions'
import { PipStage } from './layout/PipStage'
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { NotificationProvider } from '@/features/notifications/NotificationProvider'
import { ConnectionStateToast } from '@livekit/components-react'
const Container = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
display: 'grid',
gridTemplateRows: 'minmax(0, 1fr) auto auto',
backgroundColor: 'primaryDark.50',
transition: 'padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
// Disable LiveKit's own border-radius on tiles so our containers
// (GridCell, Thumbnail, StageFrame) own the clipping exclusively.
'--lk-border-radius': '4px',
'& .lk-participant-tile': {
height: '100%',
},
'& .lk-participant-media': {
height: '100%',
},
'& .lk-participant-media-video': {
height: '100%',
objectFit: 'cover',
},
'& .lk-grid-layout': {
height: '100%',
width: '100%',
},
},
variants: {
isReactionToolbarOpen: {
true: {
paddingBottom:
'calc(var(--sizes-room-reaction-toolbar-height) + var(--sizes-room-control-bar) + 1.125rem)',
},
false: {
paddingBottom: 'var(--sizes-room-control-bar)',
},
},
},
})
const ConnectionStateWrapper = styled('div', {
base: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
pointerEvents: 'none',
'& > *': { pointerEvents: 'auto' },
},
})
export const PipView = () => {
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<Container isReactionToolbarOpen={isReactionToolbarOpen}>
<ConnectionStateWrapper>
<ConnectionStateToast />
</ConnectionStateWrapper>
<PipStage />
<ReactionsToolbar adjustedCentering={false} />
<PipControlBar showScreenShare={false} />
<PipFloatingReactions />
<NotificationProvider bottom={30} />
</Container>
)
}
@@ -1,129 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useDismissOnEscape } from '../../hooks/useDismissOnEscape'
import { CollapsibleControl } from '@/features/pip/components/PipControlBar'
type PipOptionsMenuProps = {
overflowControls: Set<CollapsibleControl>
}
/**
* PiP-native options menu.
*
* Why not use the shared `<Menu>` primitive (React Aria's MenuTrigger +
* Popover)?
*
* React Aria positions popovers by reading `window.innerWidth` and
* `window.innerHeight` to know where the viewport edges are. The problem:
* it reads them from the *module-global* `window`, which is always the
* main browser window — even when the trigger button lives inside the
* Picture-in-Picture window (a separate `document` with its own, smaller
* viewport). React Aria therefore thinks it has the full main-window
* space to work with, and places the popover using those coordinates.
* The result is a menu that appears off-screen, clipped, or in the wrong
* corner of the PiP.
*
* The same single-document assumption breaks focus: React Aria's focus
* management and outside-click detection listen on the main `document`,
* so when the user interacts in the PiP, the menu doesn't receive focus
* on open, Escape doesn't restore focus to the trigger, and clicking
* outside doesn't dismiss it.
*
* These aren't bugs we can fix from the outside — the `window` and
* `document` references are baked into React Aria internals, with no
* prop or context to override them.
*
* So in PiP we replace the primitive with this component.
*/
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useDismissOnEscape(wrapperRef, isOpen, () => {
setIsOpen(false)
requestAnimationFrame(() => triggerRef.current?.focus())
})
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
const handleMenuItemClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.querySelector('button')?.contains(target)) return
if (target.closest('[role="menuitem"]')) {
requestAnimationFrame(() => {
setIsOpen(false)
triggerRef.current?.focus()
})
}
}
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.contains(target)) return
setIsOpen(false)
}
doc.addEventListener('click', handleMenuItemClick, true)
doc.addEventListener('mousedown', handleOutsideClick, true)
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
doc.removeEventListener('mousedown', handleOutsideClick, true)
}
}, [isOpen])
return (
<div
ref={wrapperRef}
className={css({
position: 'relative',
})}
>
<Button
ref={triggerRef}
id="room-options-trigger"
square
variant="primaryDark"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
tooltip={label}
onPress={() => setIsOpen(!isOpen)}
>
<RiMoreFill />
</Button>
{isOpen && (
<div
className={css({
position: 'absolute',
left: '50%',
bottom: 'calc(100% + 0.85rem)',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
</FocusScope>
</div>
)}
</div>
)
}
@@ -1,71 +0,0 @@
import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
import { CollapsibleControl, CollapsibleControls } from '../PipControlBar'
import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useTranslation } from 'react-i18next'
import { Track } from 'livekit-client'
type PipOverflowItemsProps = {
overflowControls: Set<CollapsibleControl>
}
export const PipOptionsMenuItems = ({
overflowControls,
}: PipOverflowItemsProps) => {
const { t } = useTranslation('rooms')
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const { buttonProps: screenShareProps, enabled: isScreenSharing } =
useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: { audio: true, selfBrowserSurface: 'include' },
})
const { toggle: toggleReactions } = useReactionsToolbar()
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<PictureInPictureMenuItem />
{overflowControls.has(CollapsibleControls.REACTIONS) && (
<MenuItem onAction={toggleReactions} className={itemClass}>
<RiEmotionLine size={20} />
{t('controls.reactions.button')}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.SCREEN_SHARE) && (
<MenuItem
onAction={() =>
screenShareProps.onClick?.(
{} as React.MouseEvent<HTMLButtonElement>
)
}
className={itemClass}
>
<RiArrowUpLine size={20} />
{t(
isScreenSharing
? 'controls.screenShare.stop'
: 'controls.screenShare.start'
)}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.HAND) && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
</MenuItem>
)}
</RACMenu>
)
}
@@ -1,81 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1rem',
bottom: '1rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -1,76 +0,0 @@
import { memo, useMemo, useRef } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[]
}
/**
* Adaptive grid used when 3+ tracks are visible in the PiP window.
*
* All grid math (shape choice + partial-row stretching) is delegated to
* `computePipGridLayout`. This component only measures the container,
* applies the returned placements, and plays a FLIP animation when the
* tile set or grid shape changes (participant joins/leaves or shape shift).
*
* Tiles keep a stable key so resizing never remounts <video> elements.
*/
export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width, height } = usePipElementSize(containerRef)
const { rows, subColumns, placements } = useMemo(
() => computePipGridLayout(tracks.length, width, height),
[tracks.length, width, height]
)
const gridStyle = useMemo(
() => ({
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}),
[subColumns, rows]
)
return (
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} />
</GridCell>
))}
</GridContainer>
)
})
PipGridLayout.displayName = 'PipGridLayout'
const GridContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.25rem',
},
})
const GridCell = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
willChange: 'transform',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
@@ -1,77 +0,0 @@
import { useMemo } from 'react'
import { useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { StageFrame } from './StageFrame'
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
/**
* PipStage picks between two layouts based on track count:
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
* - Grid mode (3+ tracks): adaptive tiling.
*/
export const PipStage = () => {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const screenShareTrack = useMemo(() => {
return tracks
.filter((track) => isTrackReference(track))
.find((track) => track.publication.source === Track.Source.ScreenShare)
}, [tracks])
const cameraTracks = useMemo(
() =>
tracks.filter(
(track: TrackReferenceOrPlaceholder) =>
track.source === Track.Source.Camera
),
[tracks]
)
if (tracks.length === 0) return null
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (tracks.length > 2) {
// Grid mode: 3+ tracks. Screen share goes first so it leads the grid.
const gridTracks = screenShareTrack
? [screenShareTrack, ...cameraTracks]
: cameraTracks
return (
<StageFrame>
<PipGridLayout tracks={gridTracks} />
</StageFrame>
)
}
const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal
)
const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal
)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack
return (
<StageFrame>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
</StageFrame>
)
}
@@ -1,25 +0,0 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
return (
<Container role="region" aria-label={t('stage')} {...{ inert: '' }}>
{children}
</Container>
)
}
const Container = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
marginLeft: '0.5rem',
marginRight: '0.5rem',
borderRadius: '4px',
overflow: 'hidden',
},
})
@@ -1,28 +0,0 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useDismissOnEscape = (
ref: RefObject<HTMLElement | null>,
isActive: boolean,
onDismiss: () => void
) => {
const latestOnDismiss = useRef(onDismiss)
useEffect(() => {
latestOnDismiss.current = onDismiss
})
useEffect(() => {
if (!isActive) return
const el = ref.current
if (!el) return
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()
latestOnDismiss.current()
}
el.addEventListener('keydown', handler)
return () => el.removeEventListener('keydown', handler)
}, [ref, isActive])
}
@@ -1,116 +0,0 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next'
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
export const usePictureInPicture = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { window: pipWindowRef } = useSnapshot(documentPictureInPictureStore)
const isOpen = useMemo(() => {
return !!pipWindowRef && !pipWindowRef.closed
}, [pipWindowRef])
const syncStyles = useCallback((pipWindow: Window) => {
document.head
.querySelectorAll('link[rel="stylesheet"], style')
.forEach((node) => {
pipWindow.document.head.appendChild(node.cloneNode(true))
})
pipWindow.document.documentElement.className =
document.documentElement.className
pipWindow.document.documentElement.style.cssText =
document.documentElement.style.cssText
const theme = document.documentElement.dataset.lkTheme
if (theme) {
pipWindow.document.documentElement.dataset.lkTheme = theme
}
}, [])
const initializePortalContainer = useCallback((pipWindow: Window) => {
const existing = pipWindow.document.getElementById('root')
if (existing) return existing
const newContainer = pipWindow.document.createElement('div')
newContainer.id = 'root'
newContainer.style.width = '100%'
newContainer.style.height = '100%'
pipWindow.document.body.appendChild(newContainer)
}, [])
const initializeTitleAndLanguage = useCallback(
(pipWindow: Window, title: string) => {
const parentLang = document?.documentElement.lang || 'en'
pipWindow.document.documentElement.setAttribute('lang', parentLang)
pipWindow.document.title = title
},
[]
)
const open = useCallback(
async (width = 400, height = 480) => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
try {
const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
pipWindow.addEventListener('beforeunload', () => cleanUp(), {
once: true,
})
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
return null
}
},
[
initializePortalContainer,
initializeTitleAndLanguage,
isOpen,
syncStyles,
t,
]
)
const close = useCallback(() => {
documentPictureInPictureStore.window?.close()
documentPictureInPictureStore.window = null
}, [])
const toggle = useCallback(async () => {
if (isOpen) close()
else await open()
}, [isOpen, close, open])
return {
isSupported: IS_PIP_SUPPORTED,
isOpen,
open,
close,
toggle,
}
}
@@ -1,42 +0,0 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
type Size = { width: number; height: number }
/**
* Observes an element's size, even when mounted in the PiP document.
* Resolves `ResizeObserver` from the element's own window.
*/
export const usePipElementSize = <T extends HTMLElement>(
ref: RefObject<T | null>
): Size => {
const [size, setSize] = useState<Size>({ width: 0, height: 0 })
const measure = useCallback(() => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
setSize({ width: rect.width, height: rect.height })
}, [ref])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
const RO =
el.ownerDocument.defaultView?.ResizeObserver ?? globalThis.ResizeObserver
if (!RO) return
const observer = new RO((entries) => {
const entry = entries[0]
if (!entry) return
const { width, height } = entry.contentRect
setSize({ width, height })
})
observer.observe(el)
return () => observer.disconnect()
}, [ref, measure])
return size
}
@@ -1,114 +0,0 @@
export type PipTilePlacement = {
gridColumn: string
gridRow: number
}
export type PipGridLayout = {
cols: number
rows: number
/** Number of CSS sub-columns; use as `repeat(subColumns, 1fr)`. */
subColumns: number
/** One entry per tile, in input order. */
placements: PipTilePlacement[]
}
/**
* Target tile aspect ratio used to score candidate grid shapes.
*
* Video sources are 16:9, but picking 16:9 as the target makes the
* scorer indifferent between a stretched 2-col slab (aspect ~2.7) and a
* squarer 3-col tile (aspect ~1.2) because log distance is symmetric.
* The UI works better with square, face-friendly tiles. This target keeps
* wide windows from collapsing to 2 columns with short, stretched rows
* and pushes the scorer to add a column instead.
*/
const TARGET_TILE_ASPECT = 1
/**
* Smallest count from which we force at least two columns.
* For 1-3 participants it is acceptable to stack vertically in tall
* windows, but from 4 people onwards we keep >=2 columns to
* avoid endless vertical scrolling; the scorer handles the rest.
*/
const FORCE_TWO_COLS_COUNT = 4
const pickGridShape = (
count: number,
width: number,
height: number
): { cols: number; rows: number } => {
if (count <= 1) return { cols: 1, rows: Math.max(1, count) }
if (width <= 0 || height <= 0) return { cols: count, rows: 1 }
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
let best = {
cols: minCols,
rows: Math.ceil(count / minCols),
score: -Infinity,
}
for (let cols = minCols; cols <= count; cols++) {
const rows = Math.ceil(count / cols)
const tileW = width / cols
const tileH = height / rows
if (tileW <= 0 || tileH <= 0) continue
// Score: aspect close to target, few empty cells, large tile area,
// and a tiny bias toward fewer rows so ties (perfectly square shapes)
// resolve in favour of a shorter, wider grid.
const aspectScore = -Math.abs(Math.log(tileW / tileH / TARGET_TILE_ASPECT))
const emptyCells = cols * rows - count
const fillScore = -emptyCells * 0.1
const areaScore = Math.log(tileW * tileH) * 0.5
const rowsPenalty = -rows * 0.01
const score = aspectScore * 2 + fillScore + areaScore + rowsPenalty
if (score > best.score) best = { cols, rows, score }
}
return { cols: best.cols, rows: best.rows }
}
/**
* Pure function. Given a tile count and stage dimensions, returns the CSS
* grid layout for the PiP stage:
*
* - picks a cols x rows shape close to 16:9 tiles,
* - stretches any partial last row so its tiles share the full row width
* (no empty cells, no small centered tile).
*
* Callers consume the result directly: `subColumns` feeds
* `grid-template-columns: repeat(N, 1fr)` and each tile reads its own
* `gridColumn`/`gridRow` from `placements`.
*/
export const computePipGridLayout = (
count: number,
width: number,
height: number
): PipGridLayout => {
if (count <= 0) {
return { cols: 1, rows: 1, subColumns: 1, placements: [] }
}
const { cols, rows } = pickGridShape(count, width, height)
const tilesInLastRow = count - cols * (rows - 1)
const hasPartialRow = tilesInLastRow > 0 && tilesInLastRow < cols
const subColumns = hasPartialRow ? cols * tilesInLastRow : cols
const fullRowSpan = hasPartialRow ? tilesInLastRow : 1
const lastRowSpan = hasPartialRow ? cols : 1
const placements: PipTilePlacement[] = []
for (let i = 0; i < count; i++) {
const row = Math.floor(i / cols)
const colIndex = i % cols
const isLastRow = row === rows - 1 && hasPartialRow
const span = isLastRow ? lastRowSpan : fullRowSpan
const colStart = colIndex * span + 1
placements.push({
gridColumn: `${colStart} / span ${span}`,
gridRow: row + 1,
})
}
return { cols, rows, subColumns, placements }
}
@@ -1,16 +0,0 @@
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
/**
* Produces a stable React key for a track so resizes/reshuffles of the grid
* do not remount the underlying <video> element.
*/
export const getTrackKey = (track: TrackReferenceOrPlaceholder): string => {
const identity = track.participant?.identity ?? 'unknown'
if (isTrackReference(track)) {
return `${identity}::${track.source}::${track.publication.trackSid}`
}
return `${identity}::${track.source}::placeholder`
}
@@ -15,8 +15,6 @@ const focusReactionsToolbar = () => {
?.focus()
}
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
@@ -37,7 +35,7 @@ export const ReactionsToggle = () => {
return (
<ToggleButton
id={REACTIONS_TOGGLE_ID}
id="reactions-toggle"
data-attr="reactions-toggle"
square
variant="primaryDark"
@@ -1,227 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { styled } from '@/styled-system/jsx'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { useIsMobile } from '@/utils/useIsMobile'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
import { FocusScope } from '@react-aria/focus'
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
borderRadius: '21px',
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
},
})
const StyledScrollViewport = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
overflowX: 'auto',
padding: '0.19rem',
scrollBehavior: 'smooth',
minWidth: 0,
flex: '1 1 auto',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
'& > *': {
flexShrink: 0,
},
},
})
const SCROLL_AMOUNT = 120 // roughly 3 buttons
export const ReactionButtonsContainer = ({
children,
adjustedCentering = true,
}: {
children: React.ReactNode
adjustedCentering?: boolean
}) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const { width } = useSize(scrollRef)
const [isVisible, setIsVisible] = useState(false)
const [overflowing, setOverflowing] = useState(false)
const [atStart, setAtStart] = useState(true)
const [atEnd, setAtEnd] = useState(false)
const [
shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton,
] = useState(false)
const [rightOffset, setRightOffset] = useState(0)
const updateArrows = useCallback(() => {
const el = scrollRef.current
if (!el) return
setOverflowing(el.scrollWidth > el.clientWidth + 1)
setAtStart(el.scrollLeft <= 0)
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 1)
}, [])
useEffect(() => {
if (isMobile || !adjustedCentering) return
const region = document.getElementById(CONTROL_BAR_REGION_ID)
if (!region) return
const check = () => {
setShouldBeCenteredWithToggleButton(
window.innerWidth > region.clientWidth
)
}
check()
const ro = new ResizeObserver(check)
ro.observe(region)
window.addEventListener('resize', check)
return () => {
ro.disconnect()
window.removeEventListener('resize', check)
}
}, [isMobile, adjustedCentering])
useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) {
setRightOffset(0)
return
}
const container = containerRef.current
if (!container) return
let frame = 0
const align = () => {
const toggle = document.getElementById(REACTIONS_TOGGLE_ID)
if (!toggle) return
const toggleRect = toggle.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const toggleCenterX = toggleRect.left + toggleRect.width / 2
const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return
setRightOffset((prev) => prev - shift * 2)
}
const schedule = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(align)
}
schedule()
const ro = new ResizeObserver(schedule)
ro.observe(container)
const region = document.getElementById(CONTROL_BAR_REGION_ID)
if (region) ro.observe(region)
const toggle = document.getElementById(REACTIONS_TOGGLE_ID)
if (toggle) ro.observe(toggle)
window.addEventListener('resize', schedule)
return () => {
cancelAnimationFrame(frame)
ro.disconnect()
window.removeEventListener('resize', schedule)
}
}, [shouldBeCenteredWithToggleButton, isMobile, isOpen])
useEffect(() => {
if (isOpen) {
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
}
setIsVisible(false)
}, [isOpen])
useEffect(() => {
updateArrows()
}, [width, updateArrows])
const scrollBy = (delta: number) => {
scrollRef.current?.scrollBy({ left: delta, behavior: 'smooth' })
}
return (
<StyledContainer
ref={containerRef}
aria-hidden={!isOpen}
isVisible={isVisible}
style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { marginRight: `${rightOffset}px` }
: { margin: '0 15px' }
}
>
{overflowing && (
<div aria-hidden="true">
<Button
onPress={() => scrollBy(-SCROLL_AMOUNT)}
variant="primaryTextDark"
size="sm"
isDisabled={atStart}
round
excludeFromTabOrder
>
<RiArrowLeftSLine />
</Button>
</div>
)}
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<ReactionsKeyboardNavigation>
<StyledScrollViewport ref={scrollRef} onScroll={updateArrows}>
{children}
</StyledScrollViewport>
</ReactionsKeyboardNavigation>
</FocusScope>
{overflowing && (
<div aria-hidden="true">
<Button
onPress={() => scrollBy(SCROLL_AMOUNT)}
variant="primaryTextDark"
size="sm"
isDisabled={atEnd}
round
excludeFromTabOrder
>
<RiArrowRightSLine />
</Button>
</div>
)}
</StyledContainer>
)
}
@@ -1,63 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useFocusManager } from '@react-aria/focus'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
type Props = {
children: React.ReactNode
toggleId?: string
controlBarId?: string
}
export const ReactionsKeyboardNavigation = ({
children,
toggleId = 'reactions-toggle',
controlBarId = 'control-bar',
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const { close } = useReactionsToolbar()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
if (!e.target.matches(':focus-visible')) return
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById(toggleId)?.focus()
close()
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable(controlBarId)?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
@@ -1,9 +1,15 @@
import { FocusScope, useFocusManager } from '@react-aria/focus'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { ReactionButton } from './ReactionButton'
import { Emoji } from '../../types'
import { styled } from '@/styled-system/jsx'
import { layoutStore } from '@/stores/layout'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { useIsMobile } from '@/utils/useIsMobile'
import { useEffect, useRef, useState } from 'react'
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
import { ReactionButtonsContainer } from './ReactionButtonsContainer'
import { useTranslation } from 'react-i18next'
const Container = styled('div', {
base: {
@@ -17,11 +23,116 @@ const Container = styled('div', {
},
})
export const ReactionsToolbar = ({
adjustedCentering,
}: {
adjustedCentering?: boolean
}) => {
const StyledStrip = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
desktopOffset: {
true: {
// Ideally this value should be calculated dynamically in JavaScript to keep
// the reaction toolbar perfectly centered relative to the reaction toggle.
// However, for simplicity and to follow a pragmatic 80/20 approach,
// this value is currently hardcoded in CSS.
marginRight: '30px',
},
},
},
})
const Strip = ({ children }: { children: React.ReactNode }) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (isOpen) {
// defer one frame so the browser paints opacity:0 first
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
return (
<StyledStrip
ref={ref}
aria-hidden={!isOpen}
isVisible={isVisible}
desktopOffset={!isMobile}
>
{children}
</StyledStrip>
)
}
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById('reactions-toggle')?.focus()
layoutStore.showReactionsToolbar = false
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable('control-bar')?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
export const ReactionsToolbar = () => {
const { isOpen } = useReactionsToolbar()
const shouldMount = useDelayUnmount(isOpen, 300)
@@ -29,11 +140,16 @@ export const ReactionsToolbar = ({
return (
<Container>
<ReactionButtonsContainer adjustedCentering={adjustedCentering}>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</ReactionButtonsContainer>
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<KeyboardNavigation>
<Strip>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</Strip>
</KeyboardNavigation>
</FocusScope>
</Container>
)
}
@@ -9,8 +9,5 @@ export const useReactionsToolbar = () => {
toggle: () => {
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
},
close: () => {
layoutStore.showReactionsToolbar = false
},
}
}
@@ -32,7 +32,6 @@ import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
export const Conference = ({
roomId,
@@ -292,7 +291,6 @@ export const Conference = ({
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
</QueryAware>
@@ -7,7 +7,6 @@ import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
@@ -19,7 +18,6 @@ export const OptionsMenuItems = () => {
}}
>
<MenuSection>
<PictureInPictureMenuItem />
<TranscriptMenuItem />
<ScreenRecordingMenuItem />
<FullScreenMenuItem />
@@ -1,22 +0,0 @@
import { RiPictureInPicture2Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
export const PictureInPictureMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggle, isOpen, isSupported } = usePictureInPicture()
if (!isSupported) return null
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={toggle}
>
<RiPictureInPicture2Line size={20} />
{t(`pictureInPicture.${isOpen ? 'exit' : 'enter'}`)}
</MenuItem>
)
}
@@ -26,7 +26,6 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
export function MobileControlBar({
onDeviceError,
@@ -63,7 +62,6 @@ export function MobileControlBar({
}
hideMenu={true}
/>
<ReactionsToggle />
<HandToggle />
<Button
id="room-options-trigger"
@@ -41,8 +41,6 @@ import { ReactionPortals } from '@/features/reactions/components/ReactionPortals
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
import { GridLayout } from '@/features/layout/components/GridLayout'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
/**
* @public
@@ -123,8 +121,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
(track) => !isEqualTrackRef(track, focusTrack)
)
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
// handle pin announcements
useEffect(() => {
@@ -254,38 +250,32 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
/>
<IsIdleDisconnectModal />
<RoomContentArea>
{isPictureInPictureOpen ? (
<PipRoomPlaceholder />
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<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>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</RoomContentArea>
<ControlBar
-14
View File
@@ -231,16 +231,6 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Ihr Videoanruf befindet sich in einem anderen Fenster.",
"description": "Im Bild-im-Bild-Modus bleiben Sie mit dem Anruf verbunden, während Sie andere Aufgaben erledigen.",
"bringBack": "Anruf hierher zurückholen"
},
"stage": "Teilnehmer",
"controlBar": "Besprechungssteuerung",
"title": "Bild-im-Bild Besprechung"
},
"options": {
"buttonLabel": "Weitere Optionen",
"items": {
@@ -251,10 +241,6 @@
"username": "Deinen Namen aktualisieren",
"effects": "Effekte anwenden",
"switchCamera": "Kamera wechseln",
"pictureInPicture": {
"enter": "Bild-im-Bild",
"exit": "Bild-im-Bild schließen"
},
"fullscreen": {
"enter": "Vollbild",
"exit": "Vollbildmodus verlassen"
-14
View File
@@ -231,16 +231,6 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Your video call is in another window.",
"description": "Picture-in-Picture mode allows you to stay connected to the call while performing other tasks.",
"bringBack": "Bring the call back here"
},
"stage": "Participants",
"controlBar": "Meeting controls",
"title": "Picture-in-picture meeting"
},
"options": {
"buttonLabel": "More Options",
"items": {
@@ -251,10 +241,6 @@
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"switchCamera": "Switch camera",
"pictureInPicture": {
"enter": "Picture-in-picture",
"exit": "Close picture-in-picture"
},
"fullscreen": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
-14
View File
@@ -231,16 +231,6 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Votre appel vidéo est dans une autre fenêtre.",
"description": "Le mode image dans l'image vous permet de rester connecté à l'appel tout en effectuant d'autres tâches.",
"bringBack": "Ramener l'appel ici"
},
"stage": "Participants",
"controlBar": "Commandes de la réunion",
"title": "Réunion en image dans l'image"
},
"options": {
"buttonLabel": "Plus d'options",
"items": {
@@ -251,10 +241,6 @@
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"switchCamera": "Changer de caméra",
"pictureInPicture": {
"enter": "Image dans l'image",
"exit": "Fermer l'image dans l'image"
},
"fullscreen": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
-14
View File
@@ -231,16 +231,6 @@
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Uw videogesprek bevindt zich in een ander venster.",
"description": "Met de beeld-in-beeld-modus kunt u verbonden blijven met het gesprek terwijl u andere taken uitvoert.",
"bringBack": "Gesprek hier terughalen"
},
"stage": "Deelnemers",
"controlBar": "Vergaderbesturing",
"title": "Beeld-in-beeld vergadering"
},
"options": {
"buttonLabel": "Meer opties",
"items": {
@@ -251,10 +241,6 @@
"username": "Verander uw naam",
"effects": "Pas effecten toe",
"switchCamera": "Selecteer camera",
"pictureInPicture": {
"enter": "Beeld-in-beeld",
"exit": "Beeld-in-beeld sluiten"
},
"fullscreen": {
"enter": "Volledig scherm",
"exit": "Stop volledig scherm stand"
@@ -1,15 +0,0 @@
import { createContext, useContext } from 'react'
/**
* Signals that React Aria's default overlay positioning can't be trusted in
* this subtree because the trigger and the rendered overlay live in different
* documents (currently: picture-in-picture windows) — React Aria measures
* against the main window's viewport, so tooltips, popovers, and menus end up
* mispositioned in the host that actually displays them. When `true`, consumers
* should bypass React Aria's positioning and handle placement themselves
* (e.g. a visual-only tooltip); triggers should still carry an accessible name.
*/
export const CrossDocumentOverlaysContext = createContext(false)
export const useCrossDocumentOverlays = () =>
useContext(CrossDocumentOverlaysContext)
+9 -16
View File
@@ -6,8 +6,6 @@ import {
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { useCrossDocumentOverlays } from '@/primitives/CrossDocumentOverlaysContext'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export type TooltipWrapperProps = {
tooltip?: string
@@ -26,18 +24,13 @@ export const TooltipWrapper = ({
}: {
children: ReactNode
} & TooltipWrapperProps) => {
const isCrossDocumentOverlays = useCrossDocumentOverlays()
if (!tooltip) return children
if (isCrossDocumentOverlays)
return <VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
return (
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
@@ -48,31 +41,31 @@ export const TooltipWrapper = ({
*/
const StyledTooltip = styled(RACTooltip, {
base: {
boxShadow: '0 var(--sizes-tooltip-spacing) 20px rgba(0 0 0 / 0.1)',
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
borderRadius: '4px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
forcedColorAdjust: 'none',
outline: 'none',
padding: '2px var(--sizes-tooltip-spacing)',
padding: '2px 8px',
maxWidth: '200px',
textAlign: 'center',
fontSize: 14,
transform: 'translate3d(0, 0, 0)',
'&[data-placement=top]': {
marginBottom: 'var(--sizes-tooltip-spacing)',
marginBottom: '8px',
'--origin': 'translateY(4px)',
},
'&[data-placement=bottom]': {
marginTop: 'var(--sizes-tooltip-spacing)',
marginTop: '8px',
'--origin': 'translateY(-4px)',
},
'&[data-placement=right]': {
marginLeft: 'var(--sizes-tooltip-spacing)',
marginLeft: '8px',
'--origin': 'translateX(-4px)',
},
'&[data-placement=left]': {
marginRight: 'var(--sizes-tooltip-spacing)',
marginRight: '8px',
'--origin': 'translateX(4px)',
},
'& .react-aria-OverlayArrow svg': {
@@ -1,18 +1,15 @@
import {
type ReactElement,
cloneElement,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
export type VisualOnlyTooltipProps = {
children: ReactNode
children: ReactElement
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
@@ -35,29 +32,19 @@ export const VisualOnlyTooltip = ({
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const [computedStyle, setComputedStyle] = useState<{
left: number
arrowLeft: number
} | null>(null)
const [effectiveBottom, setEffectiveBottom] = useState(
tooltipPosition === 'bottom'
)
const isBottom = tooltipPosition === 'bottom'
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
const preferBottom = tooltipPosition === 'bottom'
setEffectiveBottom(preferBottom)
setPosition({
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
top: isBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2,
})
setIsVisible(true)
@@ -66,67 +53,15 @@ export const VisualOnlyTooltip = ({
const hideTooltip = () => {
setIsVisible(false)
setPosition(null)
setComputedStyle(null)
}
useLayoutEffect(() => {
if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position)
return
const tooltipRect = tooltipRef.current.getBoundingClientRect()
const triggerRect = wrapperRef.current.getBoundingClientRect()
const doc = tooltipRef.current.ownerDocument
const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth
const padding = 8
// Vertical flip: if tooltip overflows the top, switch to bottom
if (!effectiveBottom && position.top - tooltipRect.height < 0) {
const flippedTop = triggerRect.bottom + 8
setEffectiveBottom(true)
setPosition({ top: flippedTop, left: position.left })
return
}
// Horizontal clamping (both edges)
const desiredLeft = position.left - tooltipRect.width / 2
const minLeft = padding
const maxLeft = viewportWidth - padding - tooltipRect.width
if (desiredLeft >= minLeft && desiredLeft <= maxLeft) {
setComputedStyle(null)
return
}
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
setComputedStyle({
left: clampedLeft,
arrowLeft: position.left - clampedLeft,
})
}, [isVisible, position, effectiveBottom])
const portalContainer = useMemo(() => {
if (getContainer) return getContainer()
return wrapperRef.current?.ownerDocument?.body ?? document.body
}, [getContainer])
const tooltipData = isVisible && position ? { isVisible, position } : null
const wrappedChild = isValidElement(children)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
})
: children
const translateY = effectiveBottom ? 'translateY(0)' : 'translateY(-100%)'
const translateXY = effectiveBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)'
const tooltipInlineStyle: React.CSSProperties & Record<string, string> = {
top: `${position?.top}px`,
left: computedStyle ? `${computedStyle.left}px` : `${position?.left}px`,
transform: computedStyle ? translateY : translateXY,
...(computedStyle
? { '--tooltip-arrow-left': `${computedStyle.arrowLeft}px` }
: null),
}
return (
<>
<div
@@ -138,14 +73,11 @@ export const VisualOnlyTooltip = ({
>
{wrappedChild}
</div>
{isVisible &&
position &&
portalContainer &&
{tooltipData &&
createPortal(
<div
aria-hidden="true"
role="presentation"
ref={tooltipRef}
className={css({
position: 'fixed',
padding: '2px 8px',
@@ -155,15 +87,15 @@ export const VisualOnlyTooltip = ({
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 100001,
zIndex: 9999,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
left: 'var(--tooltip-arrow-left, 50%)',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
...(effectiveBottom
...(isBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
@@ -174,11 +106,17 @@ export const VisualOnlyTooltip = ({
}),
},
})}
style={tooltipInlineStyle}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
}}
>
{tooltip}
</div>,
portalContainer
document.body
)}
</>
)
@@ -211,9 +211,6 @@ export const buttonRecipe = cva({
outlineColor: 'focusRing',
outlineOffset: '2px',
},
'&[data-disabled]': {
opacity: 0.2,
},
},
quaternaryText: {
backgroundColor: 'transparent',
@@ -1,9 +0,0 @@
import { proxy } from 'valtio'
type State = {
window: Window | null
}
export const documentPictureInPictureStore = proxy<State>({
window: null,
})
@@ -105,9 +105,8 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
ROOM_TELEPHONY_ENABLED: True
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
@@ -6,26 +6,7 @@ _summaryEnvVars: &summaryEnvVars
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
# Note: setting allowed_push_to_docs false because it won't work in dev mode
AUTHORIZED_TENANTS: >
[
{
"id": "dictaphone",
"api_key": "dictaphone_token",
"webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/",
"webhook_api_key": "token_summary",
"allowed_push_to_docs": false
},
{
"id": "visio",
"api_key": "password",
"webhook_url": "https://meet.127.0.0.1.nip.io/api/v1.0/recordings/external-process-hook/",
"webhook_api_key": "webhook-password",
"allowed_push_to_docs": false
}
]
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
IS_DOCS_INTEGRATION_ENABLED: false
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/", "webhook_api_key": "token_summary"}]'
WHISPERX_API_KEY:
secretKeyRef:
name: secret-dev
@@ -128,9 +109,8 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
ROOM_TELEPHONY_ENABLED: True
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
@@ -278,23 +258,6 @@ celerySummaryBackend:
- "-Q"
- "call-webhook-queue-v2"
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
agentMetadata:
replicas: 1
envVars:
+1 -2
View File
@@ -14,8 +14,7 @@ dependencies = [
"posthog==7.9.12",
"requests==2.33.0",
"sentry-sdk[fastapi, celery]==2.54.0",
"langfuse==4.0.0",
"ruff>=0.15.6",
"langfuse==4.0.0"
]
[project.optional-dependencies]
+4 -1
View File
@@ -2,8 +2,11 @@
from fastapi import APIRouter, Depends
from summary.api.route import tasks_v2
from summary.api.route import tasks, tasks_v2
from summary.core.security import verify_tenant_api_key
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
+79
View File
@@ -0,0 +1,79 @@
"""API routes related to application tasks."""
import time
from typing import Optional
from celery.result import AsyncResult
from fastapi import APIRouter
from pydantic import BaseModel, field_validator
from summary.core.celery_worker import (
process_audio_transcribe_summarize_v2,
)
from summary.core.config import get_settings
settings = get_settings()
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
owner_id: str
recording_filename: str
metadata_filename: Optional[str] = None
email: str
sub: str
version: Optional[int] = 2
room: Optional[str]
owner_timezone: Optional[str]
language: Optional[str]
download_link: Optional[str]
context_language: Optional[str] = None
recording_start_at: Optional[str] = None
recording_end_at: Optional[str] = None
@field_validator("language")
@classmethod
def validate_language(cls, v):
"""Validate 'language' parameter."""
if v is not None and v not in settings.whisperx_allowed_languages:
raise ValueError(
f"Language '{v}' is not allowed. "
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
)
return v
router_tasks_v1 = APIRouter(prefix="/tasks")
@router_tasks_v1.post("/")
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
"""Create a transcription and summarization task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
request.recording_filename,
request.metadata_filename,
request.email,
request.sub,
time.time(),
request.room,
request.owner_timezone,
request.language,
request.download_link,
request.context_language,
request.recording_start_at,
request.recording_end_at,
],
queue=settings.transcribe_queue,
)
return {"id": task.id, "message": "Task created"}
@router_tasks_v1.get("/{task_id}")
async def get_task_status(task_id: str):
"""Check task status by ID."""
task = AsyncResult(task_id)
return {"id": task_id, "status": task.status}
+2 -18
View File
@@ -1,10 +1,7 @@
"""API routes related to application tasks (V2 / tenant friendly)."""
import logging
from datetime import datetime, timezone
from celery.result import AsyncResult
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request
from summary.core.celery_worker import (
celery,
@@ -23,7 +20,6 @@ from summary.core.shared_models import (
TranscribeWebhookSuccessPayload,
)
logger = logging.getLogger(__name__)
router_tasks_v2 = APIRouter()
@@ -33,20 +29,8 @@ async def create_transcribe_task_v2(
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a transcription task."""
if (
request.push_to_docs_config is not None
and not request_tenant.allowed_push_to_docs
):
logger.error(
f"Push to docs is not allowed for this tenant ({request_tenant.id})."
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Push to docs is not allowed for this tenant.",
)
task = process_audio_transcribe_v2_task.apply_async(
args=[{**request.model_dump(), "tenant_id": request_tenant.id, "received_at": datetime.now(timezone.utc)}]
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
)
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
+8 -10
View File
@@ -4,14 +4,12 @@ import json
import time
from collections import Counter
from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit
import redis
from celery.utils.log import get_task_logger
from posthog import Posthog
from summary.core.config import get_settings
from summary.core.models import TranscribeTaskV2Payload
logger = get_task_logger(__name__)
settings = get_settings()
@@ -109,23 +107,23 @@ class MetadataManager:
"""Check if task_id exists in tasks metadata cache."""
return self._redis.exists(self._get_redis_key(task_id))
def create(self, task_id: str, task_payload: TranscribeTaskV2Payload):
def create(self, task_id, task_args):
"""Create initial metadata entry for a new task."""
if self._is_disabled or self.has_task_id(task_id):
return
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
_, filename, _, email, _, received_at, *_ = task_args
start_time = time.time()
parts = urlsplit(task_payload.cloud_storage_url)
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
initial_metadata = {
"start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"retries": 0,
"filename": clean_url,
"sub": task_payload.user_sub,
"email": task_payload.user_email,
"tenant_id": task_payload.tenant_id,
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
"filename": filename,
"email": email,
"queuing_time": round(start_time - received_at, 2),
}
self._save_metadata(task_id, initial_metadata)
+192 -100
View File
@@ -4,6 +4,7 @@
import json
import time
from datetime import datetime
import openai
import sentry_sdk
@@ -13,12 +14,10 @@ from requests import exceptions
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.docs_service import create_document_in_docs
from summary.core.file_service import FileService, FileServiceException
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.models import (
RecordingMetadata,
SummarizeTaskV2Payload,
TranscribeTaskV2Payload,
)
@@ -44,6 +43,7 @@ from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.user_assign import resolve_speaker_identities
from summary.core.webhook_service import (
call_webhook_v2,
submit_content,
)
settings = get_settings()
@@ -79,17 +79,23 @@ file_service = FileService()
def transcribe_audio(
*,
task_id: str,
recording_filename: str | None = None,
language: str,
cloud_storage_url: str,
cloud_storage_url=None,
raises: bool = False,
):
"""Transcribe an audio file using WhisperX.
Downloads the audio from a cloud storage URL, sends it to
Downloads the audio from MinIO or a cloud storage URL, sends it to
WhisperX for transcription, and tracks metadata throughout the process.
Returns the transcription object, or None if the file could not be retrieved.
"""
if bool(recording_filename) == bool(cloud_storage_url):
raise ValueError(
"Either filename or cloud_storage_url must be provided, but not both."
)
logger.info("Initiating WhisperX client")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key.get_secret_value(),
@@ -100,6 +106,7 @@ def transcribe_audio(
# Transcription
try:
with file_service.prepare_audio_file(
remote_object_key=recording_filename,
cloud_storage_url=cloud_storage_url,
) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
@@ -142,7 +149,11 @@ def transcribe_audio(
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
)
logger.exception(
("Unexpected error while preparing file %s "),
(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
recording_filename,
redacted_cloud_storage_url,
)
return None
@@ -152,31 +163,41 @@ def transcribe_audio(
def resolve_speaker_identities_and_apply_to(
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
) -> WhisperXResponse:
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
):
"""Assign users to detected speakers and rewrite the transcriptions.
Args:
transcription: output of meet-whisperx after transcription and diarization
recording_metadata: Metadata of the recording
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
metadata_filename: name of metadata file containing VAD information in S3
task_id: current task id, for logging purposes
"""
recording_start_dt = (
datetime.fromisoformat(recording_start_at) if recording_start_at else None
)
recording_end_dt = (
datetime.fromisoformat(recording_end_at) if recording_end_at else None
)
logger.debug(
"recording_start_dt: %s ; recording_end_dt: %s",
recording_metadata.started_at,
recording_metadata.ended_at,
recording_start_dt,
recording_end_dt,
)
if (recording_start_dt is None) or (recording_end_dt is None):
logger.debug("Skipping resolve_speaker_identities")
return transcription
logger.debug("Running resolve_speaker_identities")
try:
metadata = file_service.read_cloud_storage_json(
recording_metadata.cloud_storage_url
)
metadata = file_service.read_json(metadata_filename)
speaker_mapping = resolve_speaker_identities(
metadata,
transcription,
recording_metadata.started_at,
recording_metadata.ended_at,
recording_start_dt,
recording_end_dt,
)
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return new_transcription
@@ -204,8 +225,11 @@ def format_transcript(
transcription,
context_language: str | None,
language: str,
room: str | None,
recording_datetime: str | None,
owner_timezone: str | None,
download_link: str | None,
) -> str:
) -> tuple[str, str]:
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
@@ -218,6 +242,9 @@ def format_transcript(
return formatter.format(
transcription,
room=room,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link,
)
@@ -240,9 +267,130 @@ def format_actions(llm_output: dict) -> str:
return ""
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
recording_filename: str,
metadata_filename: str | None,
email: str,
sub: str,
received_at: float,
room: str | None,
owner_timezone: str | None,
language: str | None,
download_link: str | None,
context_language: str | None = None,
recording_start_at: str | None = None,
recording_end_at: str | None = None,
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task orchestrates:
1. Audio transcription via WhisperX
2. Transcript formatting
3. Webhook submission
4. Conditional summarization queuing
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
recording_filename: Name of the audio file in MinIO storage.
metadata_filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
recording_start_at: ISO 8601 timestamp of when file recording actually started
(from LiveKit FileInfo.started_at via the egress_ended webhook).
recording_end_at: ISO 8601 timestamp of when file recording ended
(from LiveKit FileInfo.ended_at via the egress_ended webhook).
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
)
task_id = self.request.id
# Transcribe the audio
transcription = transcribe_audio(
task_id=task_id, recording_filename=recording_filename, language=language
)
if transcription is None:
return
# Assign speakers and rewrite transcription/diarization output
if settings.is_resolve_speaker_identities_enabled and (
metadata_filename is not None
):
transcription = resolve_speaker_identities_and_apply_to(
transcription,
recording_start_at,
recording_end_at,
metadata_filename,
task_id,
)
# Format output
content, title = format_transcript(
transcription,
context_language,
language,
room,
recording_start_at,
owner_timezone,
download_link,
)
submit_content(content, title, email, sub)
metadata_manager.capture(task_id, settings.posthog_event_success)
# LLM Summarization
if (
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
and settings.is_summary_enabled
):
logger.info("Queuing summary generation task.")
summarize_transcription.apply_async(
args=[owner_id, content, email, sub, title],
queue=settings.summarize_queue,
)
else:
logger.info("Summary generation not enabled for this user. Skipping.")
@signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
task_args = args or []
metadata_manager.create(task_id, task_args)
@signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_event_failure)
def summarize_transcription_internals(
*, user_sub: str, transcript: str, session_id: str
*, owner_id: str, transcript: str, session_id: str
) -> str:
"""Generate a summary from the provided transcription text.
@@ -253,11 +401,11 @@ def summarize_transcription_internals(
"""
logger.info(
"Starting summarization task | Owner: %s",
user_sub,
owner_id,
)
user_has_tracing_consent = analytics.is_feature_enabled(
"summary-tracing-consent", distinct_id=user_sub
"summary-tracing-consent", distinct_id=owner_id
)
# NOTE: We must instantiate a new LLMObservability client for each task invocation
@@ -268,7 +416,7 @@ def summarize_transcription_internals(
llm_observability = LLMObservability(
user_has_tracing_consent=user_has_tracing_consent,
session_id=session_id,
user_id=user_sub,
user_id=owner_id,
)
llm_service = LLMService(llm_observability=llm_observability)
@@ -320,6 +468,29 @@ def summarize_transcription_internals(
return summary
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(
self, owner_id: str, transcript: str, email: str, sub: str, title: str
):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
1. Run summary internals
2. Sends the final summary via webhook.
"""
summary = summarize_transcription_internals(
owner_id=owner_id, transcript=transcript, session_id=self.request.id
)
summary_title = settings.summary_title_template.format(title=title)
submit_content(summary, summary_title, email, sub)
##################################################################################
# Tasks v2
##################################################################################
@@ -378,64 +549,6 @@ def process_audio_transcribe_v2_task(
).model_dump()
)
# Assign speakers and rewrite transcription/diarization output
if settings.is_resolve_speaker_identities_enabled and payload.metadata is not None:
try:
transcription_res = resolve_speaker_identities_and_apply_to(
transcription=transcription_res,
recording_metadata=payload.metadata,
task_id=job_id,
)
except BaseException as e:
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
# We do it synchronously for now
if (
payload.push_to_docs_config
and settings.is_docs_integration_enabled
and settings.get_authorized_tenant(
tenant_id=payload.tenant_id
).allowed_push_to_docs
):
# Format output
content = format_transcript(
transcription_res,
payload.context_language,
payload.language,
payload.push_to_docs_config.download_link,
)
create_document_in_docs(
content=content,
title=payload.push_to_docs_config.title,
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
if (
payload.push_to_docs_config.auto_create_summary
and analytics.is_feature_enabled(
"summary-enabled", distinct_id=payload.user_sub
)
and settings.is_summary_enabled
):
summary = summarize_transcription_internals(
user_sub=payload.user_sub,
transcript=content,
session_id=self.request.id,
)
locale = get_locale(payload.context_language, payload.language)
create_document_in_docs(
content=summary,
title=locale.summary_title_template.format(
title=payload.push_to_docs_config.title
),
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
metadata_manager.capture(job_id, settings.posthog_event_success)
file_service.store_transcript(
transcript=transcription_res,
job_id=job_id,
@@ -448,30 +561,9 @@ def process_audio_transcribe_v2_task(
call_webhook_v2_task.apply_async(
args=[success_payload.model_dump(), payload.tenant_id]
)
metadata_manager.capture(job_id, settings.posthog_event_success)
return success_payload.model_dump()
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
if args:
metadata_manager.create(task_id, TranscribeTaskV2Payload.model_validate(args[0]))
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_event_failure)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed(
sender,
@@ -529,7 +621,7 @@ def summarize_v2_task(
"""
payload = SummarizeTaskV2Payload.model_validate(payload)
summary = summarize_transcription_internals(
user_sub=payload.user_sub,
owner_id=payload.user_sub,
transcript=payload.content,
session_id=self.request.id,
)
+52 -14
View File
@@ -1,8 +1,9 @@
"""Application configuration and settings."""
import logging
import os
from functools import cached_property, lru_cache
from typing import Annotated, List, Mapping, Optional, Set
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
from fastapi import Depends
from pydantic import (
@@ -33,12 +34,9 @@ class AuthorizedTenant(BaseModel):
title="Webhook API Key",
description="The api_key to authenticate the webhook request.",
)
allowed_push_to_docs: bool = Field(
title="Allow Push to Docs",
description="Whether to allow pushing transcript"
" and summaries to docs for this tenant.",
default=False,
)
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
class Settings(BaseSettings):
@@ -47,12 +45,14 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", frozen=True)
app_name: str = "summary"
app_api_v1_str: str = "/api/v1"
app_api_v2_str: str = "/api/v2"
# Authorized Tenants
# Using env variables to store authorized tenants for now
# to avoid any other external dependency (DB)
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
# Audio recordings
recording_max_duration: Optional[int] = None
@@ -72,6 +72,8 @@ class Settings(BaseSettings):
celery_result_backend: str = "redis://redis/0"
celery_max_retries: int = 1
transcribe_queue: str = "transcribe-queue"
summarize_queue: str = "summarize-queue"
# v2 tasks
transcribe_queue_v2: str = "transcribe-queue-v2"
summarize_queue_v2: str = "summarize-queue-v2"
@@ -112,16 +114,15 @@ class Settings(BaseSettings):
webhook_status_forcelist: List[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1
# Locale
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
# Output related settings
summary_title_template: Optional[str] = "Résumé de {title}"
# Summary related settings
is_summary_enabled: bool = True
# Docs service configuration
is_docs_integration_enabled: bool = True
docs_base_url: str = "https://example.com"
docs_server_to_server_api_key: SecretStr = Field(
title="API key for using docs server to server api", default="NO_API_KEY"
)
# Sentry
sentry_is_enabled: bool = False
sentry_dsn: Optional[str] = None
@@ -144,6 +145,33 @@ class Settings(BaseSettings):
task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:"
@model_validator(mode="before")
@classmethod
def legacy_default_tenant_config(cls, data: Any) -> Any:
"""Migrate the legacy default tenant configuration."""
if isinstance(data, dict):
api_key = os.getenv("APP_API_TOKEN")
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
webhook_url = os.getenv("WEBHOOK_URL")
if api_key and webhook_api_key and webhook_url:
logger.warning(
"Deprecated legacy app configuration detected, "
"please use only the new 'authorized_tenants' field instead."
)
authorized_tenants = list(data.get("authorized_tenants", []))
authorized_tenants.append(
AuthorizedTenant(
id=V1_DEFAULT_TENANT_ID,
api_key=SecretStr(api_key),
webhook_url=webhook_url,
webhook_api_key=SecretStr(webhook_api_key),
)
)
data["authorized_tenants"] = tuple(authorized_tenants)
return data
@model_validator(mode="after")
def validate_authorized_tenants(self):
"""Validate authorized tenants configuration."""
@@ -162,6 +190,16 @@ class Settings(BaseSettings):
raise ValueError("Duplicate application API api_keys are not allowed")
return self
@model_validator(mode="after")
def validate_default_v1_tenant(self):
"""Validate default v1 tenant configuration."""
if not any(
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
):
raise ValueError("v1 tenant is not configured in authorized tenants")
return self
@cached_property
def authorized_tenant_api_keys(self) -> frozenset[str]:
"""Return a frozenset of authorized tenant API api_keys."""
-78
View File
@@ -1,78 +0,0 @@
"""Service for delivering content to external destinations."""
import json
import logging
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session(api_key: str | None = None):
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
if api_key:
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def _post_with_retries(*, url, data, api_key: str | None = None):
"""Send POST request with automatic retries."""
session = _create_retry_session(api_key=api_key)
try:
response = session.post(url, json=data, timeout=(20, 3 * 60))
response.raise_for_status()
return response
finally:
session.close()
def create_document_in_docs(*, content: str, title: str, email: str, sub: str) -> None:
"""Call the Docs API to create a document on behalf of the user there.
Builds the payload, sends it with retries, and logs the outcome.
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug("Submitting to %s", settings.docs_base_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(
url=settings.docs_base_url,
api_key=settings.docs_server_to_server_api_key.get_secret_value(),
data=data,
)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)
+88 -6
View File
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
import requests
from minio import Minio
from minio.error import MinioException, S3Error
from summary.core.config import get_settings
from summary.core.shared_models import WhisperXResponse
@@ -143,6 +144,54 @@ class FileService:
self._allowed_extensions = settings.recording_allowed_extensions
self._max_duration = settings.recording_max_duration
def _download_from_minio(self, remote_object_key) -> Path:
"""Download file from MinIO to local temporary file.
The file is downloaded to a temporary location for local manipulation
such as validation, conversion, or processing before being used.
"""
logger.info("Download recording | object_key: %s", remote_object_key)
if not remote_object_key:
logger.warning("Invalid object_key '%s'", remote_object_key)
raise ValueError("Invalid object_key")
extension = Path(remote_object_key).suffix.lower()
if extension not in self._allowed_extensions:
logger.warning("Invalid file extension '%s'", extension)
raise ValueError(f"Invalid file extension '{extension}'")
response = None
try:
response = self._minio_client.get_object(
self._bucket_name, remote_object_key
)
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False, prefix="minio_download_"
) as tmp:
for chunk in response.stream(self._stream_chunk_size):
tmp.write(chunk)
tmp.flush()
local_path = Path(tmp.name)
logger.info("Recording successfully downloaded")
logger.debug("Recording local file path: %s", local_path)
return local_path
except (MinioException, S3Error) as e:
raise FileServiceException(
"Unexpected error while downloading object."
) from e
finally:
if response:
response.close()
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
"""Download file from a cloud storage URL to local temporary file."""
logger.info(
@@ -155,6 +204,12 @@ class FileService:
raise ValueError("Invalid cloud_storage_url")
extension = Path(urlparse(cloud_storage_url).path).suffix.lower()
if extension not in self._allowed_extensions:
logger.warning(
"Invalid file extension '%s' from cloud_storage_url", extension
)
raise ValueError(f"Invalid file extension '{extension}'")
try:
with requests.get(
cloud_storage_url,
@@ -247,19 +302,33 @@ class FileService:
os.remove(output_path)
raise RuntimeError("Failed to extract audio.") from e
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
def read_json(self, object_name: str) -> dict:
"""Read and parse a JSON file from MinIO storage."""
logger.info("Reading JSON: %s", cloud_storage_url)
local_path = self._download_from_cloud_storage_url(cloud_storage_url)
logger.info("Reading JSON: %s", object_name)
if not object_name:
raise ValueError("Invalid object_name")
response = None
try:
return json.load(local_path.open("r"))
response = self._minio_client.get_object(self._bucket_name, object_name)
return json.loads(response.read())
except (MinioException, S3Error) as e:
raise FileServiceException(
"Unexpected error while reading JSON object."
) from e
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise FileServiceException("Invalid JSON content.") from e
finally:
if response:
response.close()
response.release_conn()
@contextmanager
def prepare_audio_file(
self,
cloud_storage_url: str,
remote_object_key: str | None = None,
cloud_storage_url: str | None = None,
):
"""Download and prepare audio file for processing.
@@ -272,7 +341,20 @@ class FileService:
file_handle = None
try:
downloaded_path = self._download_from_cloud_storage_url(cloud_storage_url)
if bool(remote_object_key) == bool(cloud_storage_url):
raise ValueError(
(
"Exactly one of 'remote_object_key' or "
"'cloud_storage_url' must be provided."
)
)
if cloud_storage_url:
downloaded_path = self._download_from_cloud_storage_url(
cloud_storage_url
)
else:
downloaded_path = self._download_from_minio(remote_object_key)
duration = self._validate_duration(downloaded_path)
-1
View File
@@ -30,5 +30,4 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
document_title_template=(
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
),
summary_title_template="Zusammenfassung von {title}",
)
-1
View File
@@ -30,5 +30,4 @@ A few things we recommend you check:
document_title_template=(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
),
summary_title_template="Summary of {title}",
)
-1
View File
@@ -30,5 +30,4 @@ Quelques points que nous vous conseillons de vérifier :
document_title_template=(
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
),
summary_title_template="Résumé de {title}",
)
-1
View File
@@ -30,5 +30,4 @@ Een paar punten die wij u aanraden te controleren:
document_title_template=(
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
),
summary_title_template="Samenvatting van {title}",
)
@@ -13,4 +13,3 @@ class LocaleStrings:
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
summary_title_template: str
+2 -56
View File
@@ -1,7 +1,6 @@
"""Models for the API & Celery tasks creation."""
from datetime import datetime
from pydantic import AwareDatetime, BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator
from summary.core.config import get_settings
from summary.core.types import Url
@@ -13,47 +12,6 @@ class SharedV2TaskCreation(BaseModel):
"""Model that holds basic information for task creation."""
user_sub: str = Field(title="User Sub", description="The user's sub.")
user_email: str | None = Field(
title="User Email", description="The user's email for analytics purposes."
)
class RecordingMetadata(BaseModel):
"""Model for recording metadata."""
cloud_storage_url: Url = Field(
title="Cloud Storage URL",
description="The URL of the metadata file for speaker assignement.",
)
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
class PushToDocsBaseConfig(BaseModel):
"""Model containing information for pushing transcript and summaries to docs."""
user_email: str = Field(
title="User Email", description="The user's email, future owner of the docs."
)
title: str = Field(title="Title", description="The title for the created document.")
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
"""Model for push to docs information for transcripts."""
download_link: str | None = Field(
title="Download Link", description="The link to download the recording."
)
auto_create_summary: bool = Field(
title="Auto Create Summary Docs",
description="Whether to automatically create a summary "
"for the transcription task and push it to docs.",
default=False,
)
class PushToDocsSummaryConfig(PushToDocsBaseConfig):
"""Model for push to docs information for summaries."""
class TranscribeTaskV2Request(SharedV2TaskCreation):
@@ -69,17 +27,7 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
description="The language of the context text.",
)
language: str = Field(
title="Language", description="The language of the content to transcribe."
)
metadata: RecordingMetadata | None = Field(
title="Metadata",
description="The metadata for the transcribe task.",
default=None,
)
push_to_docs_config: PushToDocsTranscriptConfig | None = Field(
title="Push to Docs info",
description="If set, configuration for pushing to docs",
default=None,
title="Language", description="The language of the content to summarize."
)
@field_validator("language")
@@ -98,7 +46,6 @@ class TranscribeTaskV2Payload(TranscribeTaskV2Request):
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
received_at: datetime = Field(title="Received At", description="The time the task was received.")
class SummarizeTaskV2Request(SharedV2TaskCreation):
@@ -111,4 +58,3 @@ class SummarizeTaskV2Payload(SummarizeTaskV2Request):
"""Model for creating a summarize task (used for actual task creation)."""
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
received_at: datetime = Field(title="Received At", description="The time the task was received.")
@@ -159,5 +159,4 @@ __all__ = [
"SummarizeWebhookPayloads",
"WebhookPayloads",
"WhisperXResponse",
"webhook_payload_adapter",
]
@@ -1,6 +1,9 @@
"""Transcript formatting into readable conversation format with speaker labels."""
import logging
from datetime import datetime
from typing import Tuple
from zoneinfo import ZoneInfo
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
@@ -38,9 +41,12 @@ class TranscriptFormatter:
def format(
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
) -> str:
"""Format transcription into the final document."""
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
segments = self._get_segments(transcription)
if not segments:
@@ -50,7 +56,9 @@ class TranscriptFormatter:
content = self._remove_hallucinations(content)
content = self._add_header(content, download_link)
return content
title = self._generate_title(room, recording_datetime, owner_timezone)
return content, title
def _remove_hallucinations(self, content: str) -> str:
"""Remove hallucination patterns from content."""
@@ -88,3 +96,23 @@ class TranscriptFormatter:
content = header + content
return content
def _generate_title(
self,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
) -> str:
"""Generate title from context or return default."""
if not room or not recording_datetime:
return self._locale.document_default_title
dt = datetime.fromisoformat(recording_datetime)
if owner_timezone:
dt = dt.astimezone(ZoneInfo(owner_timezone))
return self._locale.document_title_template.format(
room=room,
room_recording_date=dt.strftime("%Y-%m-%d"),
room_recording_time=dt.strftime("%H:%M"),
)
@@ -4,6 +4,9 @@ import json
import logging
import requests
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
from summary.core.shared_models import (
@@ -15,6 +18,83 @@ settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session(api_key: str | None = None):
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
if api_key:
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def _post_with_retries(*, url, data, api_key: str | None = None):
"""Send POST request with automatic retries."""
session = _create_retry_session(api_key=api_key)
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
def call_webhook_v1(*, tenant_id: str, payload: dict) -> None:
"""Call webhook with payload a payload and optional token."""
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
logger.debug("Submitting to %s", tenant.webhook_url)
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
response = _post_with_retries(
url=tenant.webhook_url,
api_key=tenant.webhook_api_key.get_secret_value(),
data=payload,
)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)
def submit_content(content: str, title: str, email: str, sub: str) -> None:
"""Submit content to the configured webhook destination.
Builds the payload, sends it with retries, and logs the outcome.
Notes:
Deprecated: Use call_webhook_v2 directly instead.
Deprecated:
This will route content to the v1 default tenant
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
call_webhook_v1(payload=data, tenant_id=settings.v1_tenant_id)
def call_webhook_v2(
*,
tenant_id: str,
+2 -1
View File
@@ -4,7 +4,7 @@ import sentry_sdk
from fastapi import FastAPI
from summary.api import health
from summary.api.main import api_router_v2
from summary.api.main import api_router_v1, api_router_v2
from summary.core.config import get_settings
settings = get_settings()
@@ -17,5 +17,6 @@ app = FastAPI(
title=settings.app_name,
)
app.include_router(api_router_v1, prefix=settings.app_api_v1_str)
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
app.include_router(health.router)
+1
View File
@@ -11,6 +11,7 @@ from summary.main import app
def get_settings_override():
"""Return settings for tests."""
return Settings(
v1_tenant_id="test-tenant",
authorized_tenants=(
AuthorizedTenant(
webhook_url="https://example.com/webhook",
-1475
View File
File diff suppressed because it is too large Load Diff