Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de50aeb4fe |
@@ -83,6 +83,3 @@ docker/livekit/out
|
||||
|
||||
# LiveKit CA configuration
|
||||
docker/livekit/rootCA.pem
|
||||
|
||||
# Frontend rollup-plugin-visualizer
|
||||
/src/frontend/rollup-plugin-visualizer/*
|
||||
|
||||
@@ -8,30 +8,15 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.17.0] - 2026-05-31
|
||||
|
||||
### 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
|
||||
- ✨(backend) add core.recording.event.parsers.S3Parser
|
||||
- ✨(summary) extended support for all video / audio files #1358
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(fullstack) simplify source serialization
|
||||
- ✨(backend) expose room configuration to all API consumers
|
||||
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
|
||||
- 🚀 (paas) remove buildpack requirements.txt to use the new uv.lock #1349
|
||||
- ✨(backend) allow room configuration and access level via external api #1260
|
||||
- ♻️(backend) prefix Swagger routes with /api
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) fix swagger and redoc documentation URLs
|
||||
|
||||
## [1.16.0] - 2026-05-13
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ docker_build(
|
||||
live_update=[
|
||||
sync('../src/backend', '/app'),
|
||||
run(
|
||||
'uv sync --locked --no-dev',
|
||||
trigger=['../src/backend/uv.lock', '../src/backend/pyproject.toml']
|
||||
'pip install -r /app/requirements.txt',
|
||||
trigger=['./api/requirements.txt']
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -110,20 +110,11 @@ k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
# Trigger once on launch
|
||||
k8s_resource(
|
||||
'meet-backend-createsuperuser',
|
||||
resource_deps=['meet-backend-migrate'],
|
||||
trigger_mode=TRIGGER_MODE_MANUAL,
|
||||
)
|
||||
k8s_resource(
|
||||
'meet-backend-migrate',
|
||||
resource_deps=['meet-backend'],
|
||||
trigger_mode=TRIGGER_MODE_MANUAL,
|
||||
)
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
|
||||
@@ -47,3 +47,4 @@ mv src/backend/* ./
|
||||
mv deploy/paas/* ./
|
||||
|
||||
echo "3.13" > .python-version
|
||||
echo "." > requirements.txt
|
||||
|
||||
@@ -185,7 +185,6 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: {}
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -199,6 +198,10 @@ paths:
|
||||
description: |
|
||||
Creates a new room with secure defaults for external API usage.
|
||||
|
||||
**Restrictions:**
|
||||
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||
- Room access_level can be updated from the webapp interface.
|
||||
|
||||
**Defaults:**
|
||||
- Delegated user is set as owner
|
||||
- Room slug auto-generated for uniqueness
|
||||
@@ -215,17 +218,8 @@ paths:
|
||||
$ref: '#/components/schemas/RoomCreate'
|
||||
examples:
|
||||
emptyBody:
|
||||
summary: No parameters (use all defaults)
|
||||
value: { }
|
||||
withAccessLevel:
|
||||
summary: Specify access level
|
||||
value:
|
||||
access_level: "trusted"
|
||||
withConfiguration:
|
||||
summary: Provide room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
summary: No parameters (default)
|
||||
value: {}
|
||||
responses:
|
||||
'201':
|
||||
description: Room created successfully
|
||||
@@ -275,7 +269,6 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: {}
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -351,56 +344,8 @@ components:
|
||||
|
||||
RoomCreate:
|
||||
type: object
|
||||
description: |
|
||||
Optional fields for room creation. All fields have secure defaults if omitted.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
Optional room behaviour settings. Unknown fields are rejected.
|
||||
All fields are optional and default to `null` (server-side defaults apply).
|
||||
properties:
|
||||
can_publish_sources:
|
||||
type: array
|
||||
nullable: true
|
||||
description: |
|
||||
Restricts which media tracks participants are allowed to publish.
|
||||
If `null`, all sources are permitted.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- camera
|
||||
- microphone
|
||||
- screen_share
|
||||
- screen_share_audio
|
||||
example: [ "camera", "microphone" ]
|
||||
everyone_can_mute:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: |
|
||||
Whether any participant can mute others, or only the room owner/moderator.
|
||||
If `null`, the server default applies.
|
||||
example: true
|
||||
additionalProperties: false
|
||||
|
||||
RoomAccessLevel:
|
||||
type: string
|
||||
enum:
|
||||
- public
|
||||
- trusted
|
||||
- restricted
|
||||
description: |
|
||||
Controls who can join the room without going through the lobby.
|
||||
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
example: "trusted"
|
||||
description: Empty object - all room properties are auto-generated
|
||||
properties: {}
|
||||
|
||||
Room:
|
||||
type: object
|
||||
@@ -417,7 +362,10 @@ components:
|
||||
description: URL-friendly room identifier (auto-generated)
|
||||
example: "aze-eere-zer"
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
type: string
|
||||
readOnly: true
|
||||
description: Room access level (always 'trusted' for API-created rooms)
|
||||
example: "trusted"
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
@@ -445,8 +393,6 @@ components:
|
||||
type: string
|
||||
description: Default country code
|
||||
example: "US"
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
OAuthError:
|
||||
type: object
|
||||
|
||||
@@ -48,7 +48,7 @@ paths:
|
||||
summary: List rooms
|
||||
description: |
|
||||
Returns a list of rooms accessible to the authenticated user.
|
||||
Only rooms where the user has access will be returned.
|
||||
Only rooms where the delegated user has access will be returned.
|
||||
operationId: listRooms
|
||||
security:
|
||||
- BearerAuth: [rooms:list]
|
||||
@@ -108,7 +108,6 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: { }
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -121,9 +120,13 @@ paths:
|
||||
summary: Create a room
|
||||
description: |
|
||||
Creates a new room with secure defaults for external API usage.
|
||||
|
||||
|
||||
**Restrictions:**
|
||||
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||
- Room access_level can be updated from the webapp interface.
|
||||
|
||||
**Defaults:**
|
||||
- user is set as owner
|
||||
- Delegated user is set as owner
|
||||
- Room slug auto-generated for uniqueness
|
||||
- Telephony PIN auto-generated when enabled
|
||||
- Creation tracked with application client_id for auditing
|
||||
@@ -138,17 +141,8 @@ paths:
|
||||
$ref: '#/components/schemas/RoomCreate'
|
||||
examples:
|
||||
emptyBody:
|
||||
summary: No parameters (use all defaults)
|
||||
value: { }
|
||||
withAccessLevel:
|
||||
summary: Specify access level
|
||||
value:
|
||||
access_level: "trusted"
|
||||
withConfiguration:
|
||||
summary: Provide room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
summary: No parameters (default)
|
||||
value: {}
|
||||
responses:
|
||||
'201':
|
||||
description: Room created successfully
|
||||
@@ -198,7 +192,6 @@ paths:
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
configuration: { }
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
@@ -217,58 +210,65 @@ components:
|
||||
Include in requests as: `Authorization: Bearer <token>`
|
||||
|
||||
schemas:
|
||||
TokenRequest:
|
||||
type: object
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- grant_type
|
||||
- scope
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: Application client identifier
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
client_secret:
|
||||
type: string
|
||||
format: password
|
||||
writeOnly: true
|
||||
description: Application secret key
|
||||
example: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
grant_type:
|
||||
type: string
|
||||
enum:
|
||||
- client_credentials
|
||||
description: OAuth2 grant type (must be 'client_credentials')
|
||||
example: "client_credentials"
|
||||
scope:
|
||||
type: string
|
||||
format: email
|
||||
description: |
|
||||
Email address of the user to delegate.
|
||||
The application will act on behalf of this user.
|
||||
Note: This parameter is named 'scope' to align with OAuth2 conventions,
|
||||
but accepts an email address to identify the user. This design allows
|
||||
for future extensibility.
|
||||
example: "user@example.com"
|
||||
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
description: JWT access token
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
|
||||
token_type:
|
||||
type: string
|
||||
description: Token type (always 'Bearer')
|
||||
example: "Bearer"
|
||||
expires_in:
|
||||
type: integer
|
||||
description: Token lifetime in seconds
|
||||
example: 3600
|
||||
scope:
|
||||
type: string
|
||||
description: Space-separated list of granted permission scopes
|
||||
example: "rooms:list rooms:retrieve rooms:create"
|
||||
|
||||
RoomCreate:
|
||||
type: object
|
||||
description: |
|
||||
Optional fields for room creation. All fields have secure defaults if omitted.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
Optional room behaviour settings. Unknown fields are rejected.
|
||||
All fields are optional and default to `null` (server-side defaults apply).
|
||||
properties:
|
||||
can_publish_sources:
|
||||
type: array
|
||||
nullable: true
|
||||
description: |
|
||||
Restricts which media tracks participants are allowed to publish.
|
||||
If `null`, all sources are permitted.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- camera
|
||||
- microphone
|
||||
- screen_share
|
||||
- screen_share_audio
|
||||
example: [ "camera", "microphone" ]
|
||||
everyone_can_mute:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: |
|
||||
Whether any participant can mute others, or only the room owner/moderator.
|
||||
If `null`, the server default applies.
|
||||
example: true
|
||||
additionalProperties: false
|
||||
|
||||
RoomAccessLevel:
|
||||
type: string
|
||||
enum:
|
||||
- public
|
||||
- trusted
|
||||
- restricted
|
||||
description: |
|
||||
Controls who can join the room without going through the lobby.
|
||||
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
example: "trusted"
|
||||
description: Empty object - all room properties are auto-generated
|
||||
properties: {}
|
||||
|
||||
Room:
|
||||
type: object
|
||||
@@ -285,7 +285,10 @@ components:
|
||||
description: URL-friendly room identifier (auto-generated)
|
||||
example: "aze-eere-zer"
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
type: string
|
||||
readOnly: true
|
||||
description: Room access level (always 'trusted' for API-created rooms)
|
||||
example: "trusted"
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
@@ -313,8 +316,6 @@ components:
|
||||
type: string
|
||||
description: Default country code
|
||||
example: "US"
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
OAuthError:
|
||||
type: object
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.49.0",
|
||||
"core-js": "^3.36.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -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",
|
||||
@@ -6863,9 +6659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
|
||||
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -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": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.49.0",
|
||||
"core-js": "^3.36.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -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,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.17.0"
|
||||
version = "1.16.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
|
||||
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.17.0"
|
||||
version = "1.16.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -8,8 +8,6 @@ from rest_framework import views as drf_views
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.utils import build_telephony_config
|
||||
|
||||
|
||||
def exception_handler(exc, context):
|
||||
"""Handle Django ValidationError as an accepted exception.
|
||||
@@ -60,7 +58,13 @@ def get_frontend_configuration(request):
|
||||
"allowed_mimetypes"
|
||||
],
|
||||
},
|
||||
"telephony": build_telephony_config(),
|
||||
"telephony": {
|
||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||
if settings.ROOM_TELEPHONY_ENABLED
|
||||
else None,
|
||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
},
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
|
||||
@@ -71,11 +71,6 @@ from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.participant_promotion import (
|
||||
AlreadyAdminException,
|
||||
OwnerPromotionException,
|
||||
ParticipantPromotionService,
|
||||
)
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
@@ -880,123 +875,6 @@ class RoomViewSet(
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="promote-participant",
|
||||
url_name="promote-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
)
|
||||
# pylint: disable=unused-argument,too-many-return-statements
|
||||
def promote_participant(self, request, pk=None): # noqa: PLR0911
|
||||
"""Promote a live participant to room admin.
|
||||
|
||||
This endpoint is intentionally non-transactional: the DB transaction and the
|
||||
LiveKit attribute update are two separate operations. If the participant
|
||||
leaves the meeting between the presence check and the LiveKit update, the
|
||||
resource access is kept — their role will be effective the next time they join.
|
||||
"""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
identity = serializer.validated_data["participant_identity"]
|
||||
participant_management = ParticipantsManagement()
|
||||
|
||||
if str(identity) == str(request.user.sub):
|
||||
return drf_response.Response(
|
||||
{"error": "You cannot promote yourself"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
is_in_meeting = participant_management.check_if_in_meeting(
|
||||
room.pk, identity=str(identity)
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
logger.warning(
|
||||
"Could not verify presence of participant %s in room %s before promotion; denying",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": "Could not verify participant presence"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not is_in_meeting:
|
||||
logger.warning(
|
||||
"Participant %s is not currently in room %s; denying promotion",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": "Could not verify participant presence"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(sub=identity)
|
||||
except models.User.DoesNotExist:
|
||||
return drf_response.Response(
|
||||
{"error": "Participant not found"},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Attempted to promote inactive user %s in room %s; denying",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "This participant account is inactive and cannot be promoted"
|
||||
},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
ParticipantPromotionService().promote_to_admin(room=room, user=user)
|
||||
except AlreadyAdminException:
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
except OwnerPromotionException:
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "Owners already have the highest privileges and cannot be promoted"
|
||||
},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# DB transaction is intentionally persisted before the LiveKit update.
|
||||
# If the participant disconnects meanwhile, the role still applies on rejoin.
|
||||
try:
|
||||
participant_management.update(
|
||||
room_name=str(room.pk),
|
||||
identity=str(identity),
|
||||
attributes={"room_admin": "true"},
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
logger.exception(
|
||||
"LiveKit update failed for participant %s in room %s; ADMIN role persisted",
|
||||
identity,
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"status": "success",
|
||||
"warning": "LiveKit update failed; role update persisted",
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from pydantic import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models, utils
|
||||
from core.api.serializers import BaseValidationOnlySerializer, RoomConfiguration
|
||||
from core.api.serializers import BaseValidationOnlySerializer
|
||||
|
||||
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
|
||||
@@ -35,37 +34,10 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
following the principle of least privilege.
|
||||
"""
|
||||
|
||||
configuration = serializers.JSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "pin_code", "access_level", "configuration"]
|
||||
read_only_fields = ["id", "name", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""Validate room configuration against the RoomConfiguration schema."""
|
||||
if value is None or value == {}:
|
||||
return value
|
||||
try:
|
||||
RoomConfiguration.model_validate(value)
|
||||
except ValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def validate_access_level(self, access_level):
|
||||
"""Reject public access_level unless explicitly allowed or the default is already public."""
|
||||
|
||||
if settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL == models.RoomAccessLevel.PUBLIC:
|
||||
return access_level
|
||||
|
||||
if (
|
||||
access_level == models.RoomAccessLevel.PUBLIC
|
||||
and not settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Public rooms are disabled for the external API."
|
||||
)
|
||||
return access_level
|
||||
fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Enrich response with application-specific computed fields."""
|
||||
@@ -96,9 +68,6 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Set secure defaults
|
||||
validated_data["name"] = utils.generate_room_slug()
|
||||
validated_data.setdefault(
|
||||
"access_level", settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL
|
||||
)
|
||||
validated_data.setdefault("configuration", {})
|
||||
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Meet storage event parser classes."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -19,9 +18,6 @@ from .exceptions import (
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
# Additional MIME type mapping
|
||||
mimetypes.add_type("audio/ogg", ".ogg")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -78,8 +74,8 @@ def get_parser() -> EventParser:
|
||||
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
||||
|
||||
|
||||
class BaseS3Parser:
|
||||
"""Base class for handling parsing and validation of S3-compatible storage events."""
|
||||
class MinioParser:
|
||||
"""Handle parsing and validation of Minio storage events."""
|
||||
|
||||
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
||||
"""Initialize parser with target bucket name and accepted filetypes."""
|
||||
@@ -95,6 +91,32 @@ class BaseS3Parser:
|
||||
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse(data):
|
||||
"""Convert raw Minio event dictionary to StorageEvent object."""
|
||||
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
|
||||
try:
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
bucket_name = s3["bucket"]["name"]
|
||||
file_object = s3["object"]
|
||||
filepath = file_object["key"]
|
||||
filetype = file_object["contentType"]
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
|
||||
try:
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
bucket_name=bucket_name,
|
||||
metadata=None,
|
||||
)
|
||||
except TypeError as e:
|
||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||
|
||||
def validate(self, event_data: StorageEvent) -> str:
|
||||
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
||||
|
||||
@@ -119,56 +141,9 @@ class BaseS3Parser:
|
||||
return recording_id
|
||||
|
||||
def get_recording_id(self, data):
|
||||
"""Extract recording ID from S3 event through parsing and validation."""
|
||||
"""Extract recording ID from Minio event through parsing and validation."""
|
||||
|
||||
event_data = self.parse(data)
|
||||
return self.validate(event_data)
|
||||
recording_id = self.validate(event_data)
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
"""To be implemented by subclasses."""
|
||||
raise NotImplementedError("Subclasses must implement parse()")
|
||||
|
||||
|
||||
class MinioParser(BaseS3Parser):
|
||||
"""Minio specific event parsing."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
try:
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
return StorageEvent(
|
||||
filepath=s3["object"]["key"],
|
||||
filetype=s3["object"]["contentType"], # Minio-specific field
|
||||
bucket_name=s3["bucket"]["name"],
|
||||
metadata=None,
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
|
||||
except TypeError as e:
|
||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||
|
||||
|
||||
class S3Parser(BaseS3Parser):
|
||||
"""AWS S3 specific event parsing."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
try:
|
||||
# AWS S3 structure can slightly differ from Minio implementation
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
filepath = s3["object"]["key"]
|
||||
if not filepath:
|
||||
raise ParsingEventDataError("Missing object key name")
|
||||
filetype, _ = mimetypes.guess_type(filepath)
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
bucket_name=s3["bucket"]["name"],
|
||||
metadata=None,
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
|
||||
return recording_id
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Participant promotion service."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from core import models
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class PromotionException(Exception):
|
||||
"""Base exception for promotion errors."""
|
||||
|
||||
|
||||
class OwnerPromotionException(PromotionException):
|
||||
"""Raised when attempting to promote an owner."""
|
||||
|
||||
|
||||
class AlreadyAdminException(PromotionException):
|
||||
"""Raised when attempting to promote a user who is already an admin."""
|
||||
|
||||
|
||||
class ParticipantPromotionService:
|
||||
"""Handles the DB side of promoting a participant to room admin."""
|
||||
|
||||
def promote_to_admin(self, room, user) -> None:
|
||||
"""Promote a user to ADMIN role for the given room."""
|
||||
|
||||
access = models.ResourceAccess.objects.filter(resource=room, user=user).first()
|
||||
|
||||
if access and access.role == models.RoleChoices.ADMIN:
|
||||
raise AlreadyAdminException(
|
||||
f"User {user.pk} is already an admin of room {room.pk}"
|
||||
)
|
||||
|
||||
if access and access.role == models.RoleChoices.OWNER:
|
||||
raise OwnerPromotionException(
|
||||
f"User {user.pk} is an owner of room {room.pk} and cannot be promoted"
|
||||
)
|
||||
|
||||
models.ResourceAccess.objects.update_or_create(
|
||||
resource=room,
|
||||
user=user,
|
||||
defaults={"role": models.RoleChoices.ADMIN},
|
||||
)
|
||||
@@ -18,13 +18,10 @@ from core.recording.event.exceptions import (
|
||||
)
|
||||
from core.recording.event.parsers import (
|
||||
MinioParser,
|
||||
S3Parser,
|
||||
StorageEvent,
|
||||
get_parser,
|
||||
)
|
||||
|
||||
# MinioParser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_minio_event():
|
||||
@@ -50,7 +47,7 @@ def minio_parser():
|
||||
return MinioParser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
|
||||
def test_parse_valid_event(minio_parser, valid_minio_event):
|
||||
"""Test parsing a valid Minio event."""
|
||||
event = minio_parser.parse(valid_minio_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
@@ -60,33 +57,13 @@ def test_minio_parse_valid_event(minio_parser, valid_minio_event):
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_minio_parse_with_video_type(minio_parser):
|
||||
"""Test parsing event with video file type."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
"contentType": "video/mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = minio_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_minio_parse_empty_data(minio_parser):
|
||||
def test_parse_empty_data(minio_parser):
|
||||
"""Test parsing empty event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
minio_parser.parse({})
|
||||
|
||||
|
||||
def test_minio_parse_missing_keys(minio_parser):
|
||||
def test_parse_missing_keys(minio_parser):
|
||||
"""Test parsing event with missing key."""
|
||||
|
||||
invalid_minio_event = {
|
||||
@@ -100,11 +77,11 @@ def test_minio_parse_missing_keys(minio_parser):
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
|
||||
with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_minio_parse_none_key(minio_parser):
|
||||
def test_parse_none_key(minio_parser):
|
||||
"""Test parsing event with None field."""
|
||||
|
||||
invalid_minio_event = {
|
||||
@@ -125,7 +102,7 @@ def test_minio_parse_none_key(minio_parser):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_minio_validate_invalid_bucket(minio_parser):
|
||||
def test_validate_invalid_bucket(minio_parser):
|
||||
"""Test validation with wrong bucket name."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
@@ -137,7 +114,7 @@ def test_minio_validate_invalid_bucket(minio_parser):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_minio_validate_invalid_filetype(minio_parser):
|
||||
def test_validate_invalid_filetype(minio_parser):
|
||||
"""Test validation with unsupported file type."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
||||
@@ -162,7 +139,7 @@ def test_minio_validate_invalid_filetype(minio_parser):
|
||||
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
|
||||
],
|
||||
)
|
||||
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
"""Test validation with malformed filepath."""
|
||||
event = StorageEvent(
|
||||
filepath=invalid_filepath,
|
||||
@@ -174,7 +151,7 @@ def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_minio_validate_valid_event(minio_parser):
|
||||
def test_validate_valid_event(minio_parser):
|
||||
"""Test validation with valid event data."""
|
||||
event = StorageEvent(
|
||||
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
@@ -186,13 +163,13 @@ def test_minio_validate_valid_event(minio_parser):
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
def test_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
"""Test successful extraction of recording ID."""
|
||||
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_minio_validate_filepath_with_folder(minio_parser):
|
||||
def test_validate_filepath_with_folder(minio_parser):
|
||||
"""Test validation of filepath with folder structure."""
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
@@ -204,21 +181,41 @@ def test_minio_validate_filepath_with_folder(minio_parser):
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_minio_empty_allowed_filetypes():
|
||||
def test_parse_with_video_type(minio_parser):
|
||||
"""Test parsing event with video file type."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
"contentType": "video/mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = minio_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_empty_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
empty_types = set()
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
||||
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
||||
|
||||
|
||||
def test_minio_custom_allowed_filetypes():
|
||||
def test_custom_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
custom_types = {"audio/mp3", "video/mov"}
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
||||
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
||||
|
||||
|
||||
def test_minio_validate_custom_filetypes():
|
||||
def test_validate_custom_filetypes():
|
||||
"""Test validation of filepath with folder structure."""
|
||||
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||
@@ -232,143 +229,18 @@ def test_minio_validate_custom_filetypes():
|
||||
parser.validate(event)
|
||||
|
||||
|
||||
def test_minio_constructor_none_bucket():
|
||||
def test_constructor_none_bucket():
|
||||
"""Test MinioParser constructor with None bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name=None)
|
||||
|
||||
|
||||
def test_minio_constructor_empty_bucket():
|
||||
def test_constructor_empty_bucket():
|
||||
"""Test MinioParser constructor with empty bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name="")
|
||||
|
||||
|
||||
# S3Parser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_s3_event():
|
||||
"""Mock a valid S3 event."""
|
||||
return {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_parser():
|
||||
"""Mock an S3 parser."""
|
||||
return S3Parser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
|
||||
"""Test parsing a valid S3 event."""
|
||||
event = s3_parser.parse(valid_s3_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filetype == "audio/ogg"
|
||||
assert event.bucket_name == "test-bucket"
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_s3_parse_empty_data(s3_parser):
|
||||
"""Test parsing empty S3 event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
s3_parser.parse({})
|
||||
|
||||
|
||||
def test_s3_parse_missing_keys(s3_parser):
|
||||
"""Test parsing S3 event with missing key."""
|
||||
invalid_s3_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
# Missing 'object' key
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
|
||||
s3_parser.parse(invalid_s3_event)
|
||||
|
||||
|
||||
def test_s3_parse_none_key(s3_parser):
|
||||
"""Test parsing S3 event with None field."""
|
||||
invalid_s3_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": None,
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
|
||||
s3_parser.parse(invalid_s3_event)
|
||||
|
||||
|
||||
def test_s3_parse_with_video_type(s3_parser):
|
||||
"""Test parsing S3 event with mp4 file extension."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = s3_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_s3_parse_unrecognized_extension(s3_parser):
|
||||
"""Test parsing S3 event with unrecognized file extension."""
|
||||
event_with_unknown_ext = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(TypeError, match="filetype cannot be None"):
|
||||
s3_parser.parse(event_with_unknown_ext)
|
||||
|
||||
|
||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||
"""Test successful extraction of recording ID from S3 event."""
|
||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
# get_parser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_lru_cache():
|
||||
"""Fixture to clear the LRU cache between tests."""
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
"""
|
||||
Test rooms API endpoints: promote participant.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import models
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ---
|
||||
# success cases
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_success_new_access(mock_participants_management_cls):
|
||||
"""Should create a new ResourceAccess with ADMIN role and update LiveKit."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_instance.check_if_in_meeting.assert_called_once_with(
|
||||
room.pk, identity=participant_user.sub
|
||||
)
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
mock_instance.update.assert_called_once_with(
|
||||
room_name=str(room.pk),
|
||||
identity=str(participant_user.sub),
|
||||
attributes={"room_admin": "true"},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_success_upgrades_member_to_admin(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should upgrade an existing member to ADMIN role."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(resource=room, user=participant_user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_instance.check_if_in_meeting.assert_called_once_with(
|
||||
room.pk, identity=str(participant_user.sub)
|
||||
)
|
||||
|
||||
access = models.ResourceAccess.objects.get(resource=room, user=participant_user)
|
||||
assert access.role == models.RoleChoices.ADMIN
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_success_already_admin(mock_participants_management_cls):
|
||||
"""Should succeed idempotently when the participant is already an admin."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=participant_user, role="administrator"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_instance.check_if_in_meeting.assert_called_once_with(
|
||||
room.pk, identity=str(participant_user.sub)
|
||||
)
|
||||
|
||||
access = models.ResourceAccess.objects.get(resource=room, user=participant_user)
|
||||
assert access.role == models.RoleChoices.ADMIN
|
||||
mock_instance.update.assert_not_called()
|
||||
|
||||
|
||||
# ---
|
||||
# permission / auth
|
||||
# ---
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_without_authentication():
|
||||
"""Should return 401 when the request is unauthenticated."""
|
||||
room = RoomFactory()
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_for_member():
|
||||
"""Should return 403 when the requester only has member-level access."""
|
||||
room = RoomFactory()
|
||||
member = UserFactory()
|
||||
UserResourceAccessFactory(resource=room, user=member, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=member)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_for_unrelated_user():
|
||||
"""Should return 403 when the requester has no access to the room."""
|
||||
room = RoomFactory()
|
||||
unrelated_user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=unrelated_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_promote_participant_forbidden_for_admin_on_another_room():
|
||||
"""Should return 403 when the requester is admin on a different room, not the target one."""
|
||||
target_room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
admin_other_room = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=other_room,
|
||||
user=admin_other_room,
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_other_room)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{target_room.id}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ---
|
||||
# self-promotion
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_self_promotion(mock_participants_management_cls):
|
||||
"""Should return 403 when the requester attempts to promote themselves."""
|
||||
mock_participants_management_cls.return_value = mock.MagicMock()
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(admin_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "You cannot promote yourself"}
|
||||
|
||||
mock_participants_management_cls.check_if_in_meeting.assert_not_called()
|
||||
|
||||
|
||||
# ---
|
||||
# presence check
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_participant_not_in_meeting(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when check_if_in_meeting returns False."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.check_if_in_meeting.return_value = False
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify participant presence"}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_presence_check_fails(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when the participant is not found in the meeting."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.check_if_in_meeting.side_effect = ParticipantNotFoundException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify participant presence"}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_presence_management_exception(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when the presence check raises a management exception."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.check_if_in_meeting.side_effect = ParticipantsManagementException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify participant presence"}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# user resolution
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_not_found_when_user_never_logged_in(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 404 when the identity has no matching db user."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
unknown_sub = uuid4()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(unknown_sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
mock_instance.update.assert_not_called()
|
||||
|
||||
assert models.ResourceAccess.objects.filter(resource=room).count() == 1
|
||||
assert models.ResourceAccess.objects.filter(resource=room, user=admin_user).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# inactive user
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_user_is_inactive(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when the target participant account is inactive."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4(), is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "This participant account is inactive and cannot be promoted"
|
||||
}
|
||||
mock_instance.update.assert_not_called()
|
||||
assert not models.ResourceAccess.objects.filter(
|
||||
resource=room, user=participant_user
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# owner protection
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_forbidden_when_target_is_owner(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 403 when trying to promote a participant who is already an owner."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
UserResourceAccessFactory(resource=room, user=participant_user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "Owners already have the highest privileges and cannot be promoted"
|
||||
}
|
||||
mock_instance.update.assert_not_called()
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# LiveKit update failures
|
||||
# ---
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_partial_success_when_livekit_participant_missing(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 200 with warning when participant left during the promotion.
|
||||
|
||||
The DB resource access is kept — they will have admin privileges on rejoin.
|
||||
"""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.update.side_effect = ParticipantNotFoundException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {
|
||||
"status": "success",
|
||||
"warning": "LiveKit update failed; role update persisted",
|
||||
}
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.ParticipantsManagement")
|
||||
def test_promote_participant_partial_success_on_livekit_management_exception(
|
||||
mock_participants_management_cls,
|
||||
):
|
||||
"""Should return 200 with warning when LiveKit update fails but DB transaction succeeded."""
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_instance.update.side_effect = ParticipantsManagementException()
|
||||
mock_participants_management_cls.return_value = mock_instance
|
||||
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
participant_user = UserFactory(sub=uuid4())
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
{"participant_identity": str(participant_user.sub)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {
|
||||
"status": "success",
|
||||
"warning": "LiveKit update failed; role update persisted",
|
||||
}
|
||||
|
||||
assert models.ResourceAccess.objects.filter(
|
||||
resource=room,
|
||||
user=participant_user,
|
||||
role=models.RoleChoices.ADMIN,
|
||||
).exists()
|
||||
|
||||
|
||||
# ---
|
||||
# payload validation
|
||||
# ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"participant_identity": "not-a-uuid"},
|
||||
{"participant_identity": ""},
|
||||
{"participant_identity": " "},
|
||||
{"participant_identity": None},
|
||||
{},
|
||||
],
|
||||
)
|
||||
def test_promote_participant_invalid_payload(payload):
|
||||
"""Should return 400 for invalid, empty, whitespace, null, or missing participant_identity."""
|
||||
room = RoomFactory()
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=admin_user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=admin_user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/promote-participant/",
|
||||
payload,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
# ---
|
||||
# room not found
|
||||
# ---
|
||||
|
||||
|
||||
def test_promote_participant_room_not_found():
|
||||
"""Should return 404 when the room does not exist."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{uuid4()}/promote-participant/",
|
||||
{"participant_identity": str(uuid4())},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -4,7 +4,6 @@ Test suite for generated openapi schema.
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import Client
|
||||
@@ -34,26 +33,10 @@ def test_openapi_client_schema():
|
||||
)
|
||||
assert output.getvalue() == ""
|
||||
|
||||
response = Client().get("/api/v1.0/swagger.json")
|
||||
response = Client().get("/v1.0/swagger.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
with open(
|
||||
"core/tests/swagger/swagger.json", "r", encoding="utf-8"
|
||||
) as expected_schema:
|
||||
assert response.json() == json.load(expected_schema)
|
||||
|
||||
|
||||
@patch(
|
||||
"django.contrib.staticfiles.storage.staticfiles_storage.url",
|
||||
side_effect=lambda name: f"/static/{name}",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def test_openapi_documentation_routes(mock_staticfiles):
|
||||
"""Swagger and ReDoc documentation should be served on canonical URLs."""
|
||||
client = Client()
|
||||
|
||||
swagger_response = client.get("/api/v1.0/swagger/")
|
||||
redoc_response = client.get("/api/v1.0/redoc/")
|
||||
|
||||
assert swagger_response.status_code == 200
|
||||
assert redoc_response.status_code == 200
|
||||
|
||||
@@ -250,112 +250,6 @@ def test_api_rooms_list_filters_by_user():
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_list_access_level_in_results():
|
||||
"""Rooms should include the correct access_level for each room."""
|
||||
user = UserFactory()
|
||||
room_trusted = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||
)
|
||||
room_restricted = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.RESTRICTED
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = {r["id"]: r for r in response.data["results"]}
|
||||
assert results[str(room_trusted.id)]["access_level"] == RoomAccessLevel.TRUSTED
|
||||
assert (
|
||||
results[str(room_restricted.id)]["access_level"] == RoomAccessLevel.RESTRICTED
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_does_not_expose_sensitive_fields():
|
||||
"""Rooms should not expose pin_code or accesses."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.data["results"][0]
|
||||
assert "pin_code" not in result
|
||||
assert "accesses" not in result
|
||||
assert "livekit" not in result
|
||||
|
||||
|
||||
def test_api_rooms_list_expected_fields(settings):
|
||||
"""Rooms should expose exactly the expected fields."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = "https://example.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert set(response.data["results"][0].keys()) == {
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"access_level",
|
||||
"configuration",
|
||||
"telephony",
|
||||
"url",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_list_expected_fields_without_telephony(settings):
|
||||
"""Rooms shouldn't expose telephony related fields when disabled."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = "https://example.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "telephony" not in set(response.data["results"][0].keys())
|
||||
|
||||
|
||||
def test_api_rooms_list_expected_fields_missing_base_url(settings):
|
||||
"""Rooms shouldn't expose URL field when the application base url is missing."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "url" not in set(response.data["results"][0].keys())
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_authentication():
|
||||
"""Retrieving rooms without authentication should return 401."""
|
||||
|
||||
@@ -489,7 +383,6 @@ def test_api_rooms_retrieve_success(settings):
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"access_level": str(room.access_level),
|
||||
"configuration": room.configuration,
|
||||
"url": f"http://your-application.com/{room.slug}",
|
||||
"telephony": {
|
||||
"enabled": True,
|
||||
@@ -672,40 +565,11 @@ def test_api_rooms_create_success():
|
||||
assert "slug" in response.data
|
||||
assert "name" in response.data
|
||||
assert response.data["name"] == response.data["slug"]
|
||||
assert response.data["configuration"] == {}
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_create_with_configuration_success():
|
||||
"""Creating a room with a validated configuration should succeed."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
assert response.data["configuration"] == {"can_publish_sources": ["camera"]}
|
||||
|
||||
|
||||
def test_api_rooms_create_readonly_enforcement():
|
||||
@@ -723,6 +587,7 @@ def test_api_rooms_create_readonly_enforcement():
|
||||
"id": "fake-id",
|
||||
"slug": "fake-slug",
|
||||
"name": "fake-name",
|
||||
"access_level": "public",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -734,150 +599,41 @@ def test_api_rooms_create_readonly_enforcement():
|
||||
assert response.data["slug"] != "fake-slug"
|
||||
assert "id" in response.data
|
||||
assert response.data["name"] != "fake-name"
|
||||
assert response.data["configuration"] == {}
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_create_rejects_invalid_configuration():
|
||||
"""Creating a room with unsupported configuration keys should fail."""
|
||||
def test_api_rooms_unknown_actions():
|
||||
"""Updating or deleting a room are not supported yet."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(
|
||||
user,
|
||||
[
|
||||
ApplicationScope.ROOMS_RETRIEVE,
|
||||
ApplicationScope.ROOMS_DELETE,
|
||||
ApplicationScope.ROOMS_UPDATE,
|
||||
],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{
|
||||
"configuration": {
|
||||
"unsupported_flag": True,
|
||||
}
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "extra inputs are not permitted" in str(response.data).lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_configuration",
|
||||
[
|
||||
{"can_publish_sources": ["invalid-source"]},
|
||||
{"everyone_can_mute": "invalid-value"},
|
||||
],
|
||||
)
|
||||
def test_api_rooms_create_rejects_invalid_configuration_values(invalid_configuration):
|
||||
"""Creating a room with invalid configuration values should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
assert response.status_code == 405
|
||||
assert 'method "delete" not allowed.' in str(response.data).lower()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"configuration": invalid_configuration},
|
||||
format="json",
|
||||
)
|
||||
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_public_access_disabled_by_default():
|
||||
"""Public rooms should be disabled for the external API by default."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "public rooms are disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_public_access_enabled_with_settings(settings):
|
||||
"""Public rooms should be creatable when explicitly enabled."""
|
||||
|
||||
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_api_rooms_create_default_access_level_respects_settings(settings):
|
||||
"""Room creation should reflect the EXTERNAL_API_DEFAULT_ACCESS_LEVEL setting."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.TRUSTED
|
||||
|
||||
settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public"
|
||||
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_api_rooms_create_public_access_level_when_default_is_public(settings):
|
||||
"""Explicit public access_level is accepted when the default is already public."""
|
||||
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = False
|
||||
settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public"
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# No access_level in body — default kicks in, public room is created.
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
# Explicit access_level=public in body — still rejected.
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
assert response.status_code == 405
|
||||
assert 'method "patch" not allowed.' in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""
|
||||
Test utils.build_telephony_config
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from core.utils import build_telephony_config
|
||||
|
||||
|
||||
def test_build_telephony_config_disabled(settings):
|
||||
"""Returns {"enabled": False} when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
config = build_telephony_config()
|
||||
assert config == {"enabled": False}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_valid_number(settings):
|
||||
"""Returns full config with country and international number when telephony is enabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "0123456789"
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
config = build_telephony_config()
|
||||
assert config == {
|
||||
"enabled": True,
|
||||
"default_country": "FR",
|
||||
"international_phone_number": "+33 1 23 45 67 89",
|
||||
}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_invalid_number(settings):
|
||||
"""Returns {"enabled": False} when phone number cannot be parsed."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "not-a-number"
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
config = build_telephony_config()
|
||||
assert config == {"enabled": False}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_missing_number(settings):
|
||||
"""Returns {"enabled": False} when phone number is not configured."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = ""
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
config = build_telephony_config()
|
||||
assert config == {"enabled": False}
|
||||
|
||||
|
||||
def test_build_telephony_config_enabled_with_missing_number_warns(settings, caplog):
|
||||
"""Logs a warning when telephony is enabled but phone number is not configured."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = ""
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
build_telephony_config()
|
||||
|
||||
assert "ROOM_TELEPHONY_PHONE_NUMBER" in caplog.text
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Test utils._format_telephony_phone_number
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from core.utils import _format_telephony_phone_number
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_lru_cache():
|
||||
"""Clear the lru_cache before each test to ensure isolation."""
|
||||
_format_telephony_phone_number.cache_clear()
|
||||
yield
|
||||
_format_telephony_phone_number.cache_clear()
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_missing_raw_number():
|
||||
"""Returns (None, None) when raw_number is empty."""
|
||||
country, international = _format_telephony_phone_number("", "FR")
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_none_raw_number():
|
||||
"""Returns (None, None) when raw_number is None."""
|
||||
country, international = _format_telephony_phone_number(None, "FR")
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_missing_default_country():
|
||||
"""Returns (None, None) when default_country is empty."""
|
||||
country, international = _format_telephony_phone_number("+33123456789", "")
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_none_default_country():
|
||||
"""Returns (None, None) when default_country is None."""
|
||||
country, international = _format_telephony_phone_number("+33123456789", None)
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_both_missing():
|
||||
"""Returns (None, None) when both inputs are missing."""
|
||||
country, international = _format_telephony_phone_number(None, None)
|
||||
assert country is None
|
||||
assert international is None
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_invalid_number(caplog):
|
||||
"""Returns (None, None) and logs a warning when the number cannot be parsed."""
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
country, international = _format_telephony_phone_number("not-a-number", "FR")
|
||||
|
||||
assert country is None
|
||||
assert international is None
|
||||
assert "not-a-number" in caplog.text
|
||||
assert "FR" in caplog.text
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_french_number():
|
||||
"""Returns correct country and international format for a valid French number."""
|
||||
country, international = _format_telephony_phone_number("0123456789", "FR")
|
||||
assert country == "FR"
|
||||
assert international == "+33 1 23 45 67 89"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_e164_number():
|
||||
"""Returns correct result for an E.164-formatted number (no default country needed)."""
|
||||
country, international = _format_telephony_phone_number("+33123456789", "US")
|
||||
assert country == "FR"
|
||||
assert international == "+33 1 23 45 67 89"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_us_number():
|
||||
"""Returns correct country and international format for a valid US number."""
|
||||
country, international = _format_telephony_phone_number("2025550123", "US")
|
||||
assert country == "US"
|
||||
assert international == "+1 202-555-0123"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_valid_german_number():
|
||||
"""Returns correct country and international format for a valid German number."""
|
||||
country, international = _format_telephony_phone_number("03012345678", "DE")
|
||||
assert country == "DE"
|
||||
assert international == "+49 30 12345678"
|
||||
|
||||
|
||||
def test_format_telephony_phone_number_lru_cache():
|
||||
"""Results are cached: the same inputs return the same object."""
|
||||
result1 = _format_telephony_phone_number("0123456789", "FR")
|
||||
result2 = _format_telephony_phone_number("0123456789", "FR")
|
||||
assert result1 is result2
|
||||
# pylint: disable=no-value-for-parameter
|
||||
cache_info = _format_telephony_phone_number.cache_info()
|
||||
assert cache_info.hits >= 1
|
||||
@@ -12,7 +12,6 @@ import mimetypes
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -23,7 +22,6 @@ import aiohttp
|
||||
import boto3
|
||||
import botocore
|
||||
import magic
|
||||
import phonenumbers
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
@@ -123,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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -457,58 +459,3 @@ def generate_upload_policy(file):
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _format_telephony_phone_number(raw_number, default_country):
|
||||
"""Parse a configured phone number and return (country, international_format).
|
||||
|
||||
Returns (None, None) if the inputs are missing or the number cannot be
|
||||
parsed. Logs a warning on parse failure so operators see the misconfiguration.
|
||||
"""
|
||||
if not raw_number or not default_country:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
parsed = phonenumbers.parse(raw_number, default_country)
|
||||
except phonenumbers.NumberParseException:
|
||||
logger.warning(
|
||||
"ROOM_TELEPHONY_PHONE_NUMBER %r is not a valid phone number for "
|
||||
"default country %r; telephony block will be returned without "
|
||||
"formatted number.",
|
||||
raw_number,
|
||||
default_country,
|
||||
)
|
||||
return None, None
|
||||
|
||||
country = phonenumbers.region_code_for_number(parsed)
|
||||
international = phonenumbers.format_number(
|
||||
parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL
|
||||
)
|
||||
return country, international
|
||||
|
||||
|
||||
def build_telephony_config():
|
||||
"""Build the telephony block of the frontend configuration."""
|
||||
if not settings.ROOM_TELEPHONY_ENABLED:
|
||||
return {"enabled": False}
|
||||
|
||||
country, international = _format_telephony_phone_number(
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER,
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
)
|
||||
|
||||
if international is None:
|
||||
logger.warning(
|
||||
"Telephony is enabled but ROOM_TELEPHONY_PHONE_NUMBER %r with "
|
||||
"default country %r could not be formatted; telephony will be disabled.",
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER,
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
)
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
@@ -908,18 +908,6 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
False,
|
||||
environ_name="EXTERNAL_API_ALLOW_PUBLIC_ACCESS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
EXTERNAL_API_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"trusted",
|
||||
environ_name="EXTERNAL_API_DEFAULT_ACCESS_LEVEL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Allows third-party platforms to create users with email-only identification.
|
||||
# Required for external integrations, but fragile due to deferred user reconciliation
|
||||
# on sub. Enable it with care /!\
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from drf_spectacular.views import (
|
||||
SpectacularJSONAPIView,
|
||||
@@ -29,7 +29,7 @@ if settings.DEBUG:
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/swagger.json",
|
||||
f"{settings.API_VERSION}/swagger.json",
|
||||
SpectacularJSONAPIView.as_view(
|
||||
api_version=settings.API_VERSION,
|
||||
urlconf="core.urls",
|
||||
@@ -37,12 +37,12 @@ if settings.USE_SWAGGER or settings.DEBUG:
|
||||
name="client-api-schema",
|
||||
),
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/swagger/",
|
||||
f"{settings.API_VERSION}//swagger/",
|
||||
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
|
||||
name="swagger-ui-schema",
|
||||
),
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/redoc/",
|
||||
re_path(
|
||||
f"{settings.API_VERSION}//redoc/",
|
||||
SpectacularRedocView.as_view(url_name="client-api-schema"),
|
||||
name="redoc-schema",
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.17.0"
|
||||
version = "1.16.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -62,7 +62,6 @@ dependencies = [
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.4",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.30",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1173,7 +1173,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.17.0"
|
||||
version = "1.16.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1204,7 +1204,6 @@ dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -1267,7 +1266,6 @@ requires-dist = [
|
||||
{ name = "markdown", specifier = "==3.10.2" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.30" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pyjwt", specifier = "==2.12.1" },
|
||||
@@ -1432,15 +1430,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phonenumbers"
|
||||
version = "9.0.30"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/f1/249f843f4107c6a6ed17e5ece17620d75e532c2a355106e26d889a0c72c7/phonenumbers-9.0.30.tar.gz", hash = "sha256:d42d232ccde69c1af1bb5916a7e46f4edbcc72975b02759830f4ea1fba7b00c9", size = 2306521, upload-time = "2026-05-07T10:20:38.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/22/e4442aabea04daf16fda50d89bce2ff585e44f204089986b2cc6679cae10/phonenumbers-9.0.30-py2.py3-none-any.whl", hash = "sha256:e0890d4cda206ef6ac18ef07e8f3ab225c31c7edce237ac870b4729d4c1d2520", size = 2595222, upload-time = "2026-05-07T10:20:35.387Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.17.0",
|
||||
"version": "1.16.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
"build": "panda codegen && tsc -b && vite build",
|
||||
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||
@@ -16,6 +15,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.17.1",
|
||||
"posthog-js": "1.342.1",
|
||||
"react": "18.3.1",
|
||||
@@ -62,10 +64,8 @@
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.14",
|
||||
"prettier": "3.8.1",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,3 +1,4 @@
|
||||
import '@livekit/components-styles'
|
||||
import '@/styles/index.css'
|
||||
import { Suspense } from 'react'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
|
||||
@@ -2,8 +2,8 @@ import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import type { Track } from 'livekit-client'
|
||||
type Source = Track.Source
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -44,7 +44,7 @@ export interface ApiConfig {
|
||||
}
|
||||
telephony: {
|
||||
enabled: boolean
|
||||
international_phone_number?: string
|
||||
phone_number?: string
|
||||
default_country?: string
|
||||
}
|
||||
manifest_link?: string
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 350 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 178 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q17-72 85-137t145-65q33 0 56.5 23.5T520-716v242l64-62 56 56-160 160-160-160 56-56 64 62v-242q-76 14-118 73.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h480q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-48-22-89.5T600-680v-93q74 35 117 103.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H260Zm220-358Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 488 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M325-111.5q-73-31.5-127.5-86t-86-127.5Q80-398 80-480.5t31.5-155q31.5-72.5 86-127t127.5-86Q398-880 480.5-880t155 31.5q72.5 31.5 127 86t86 127Q880-563 880-480.5T848.5-325q-31.5 73-86 127.5t-127 86Q563-80 480.5-80T325-111.5ZM480-162q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 996 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M480-120v-80h280v-560H480v-80h280q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H480Zm-80-160-55-58 102-102H120v-80h327L345-622l55-58 200 200-200 200Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 278 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm320-280L160-640v400h640v-400L480-440Zm0-80 320-200H160l320 200ZM160-640v-80 480-400Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 328 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M565-395q35-35 35-85t-35-85q-35-35-85-35t-85 35q-35 35-35 85t35 85q35 35 85 35t85-35ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 495 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M80-40v-80h800v80H80Zm80-120v-240q-33-54-51-114.5T91-638q0-61 15.5-120T143-874q8-21 26-33.5t40-12.5q31 0 53 21t18 50l-11 91q-6 48 8.5 91t43.5 75.5q29 32.5 70 52t89 19.5q60 0 120.5 12.5T706-472q45 23 69.5 58.5T800-326v166H160Zm80-80h480v-86q0-24-12-42.5T674-398q-41-20-95-31t-99-11q-66 0-122.5-27t-96-72.5Q222-585 202-644.5T190-768q-10 30-14.5 64t-4.5 66q0 58 20.5 111.5T240-422v182Zm127-367q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47Zm169.5-56.5Q560-687 560-720t-23.5-56.5Q513-800 480-800t-56.5 23.5Q400-753 400-720t23.5 56.5Q447-640 480-640t56.5-23.5ZM320-160v-37q0-67 46.5-115T480-360h160v80H480q-34 0-57 24.5T400-197v37h-80Zm160-80Zm0-480Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 808 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M158-242q-37-50-57.5-110.5T80-480q0-67 20-127t57-110l58 57q-26 38-40.5 83.5T160-480q0 51 14.5 97t40.5 84l-57 57ZM480-80q-67 0-127-20t-110-57l57-58q38 26 83.5 40.5T480-160q51 0 96.5-14.5T660-215l57 58q-50 37-110 57T480-80Zm322-162-57-57q26-38 40.5-84t14.5-97q0-51-14.5-96.5T745-660l58-57q37 50 57 110t20 127q0 67-20.5 127.5T802-242ZM299-745l-57-57q50-37 110.5-57.5T480-880q68 0 128 20.5T718-802l-57 57q-38-26-84-40.5T480-800q-51 0-97 14.5T299-745Zm181 465q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 677 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M680-560q-33 0-56.5-23T600-640v-160q0-34 23.5-57t56.5-23q34 0 57 23t23 57v160q0 34-23 57t-57 23ZM200-80q-33 0-56.5-23.5T120-160v-640q0-33 23.5-56.5T200-880h320v80H200v640h440v-80h80v80q0 33-23.5 56.5T640-80H200Zm80-160v-80h280v80H280Zm0-120v-80h200v80H280Zm440 40h-80v-104q-77-14-128.5-74.5T460-640h80q0 58 41 99t99 41q59 0 99.5-41t40.5-99h80q0 81-51 141.5T720-424v104Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 494 B |
@@ -1,14 +1,20 @@
|
||||
import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth/api/useSyncUserPreferencesWithBackend'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
useSyncUserPreferencesWithBackend()
|
||||
|
||||
const { analytics = {}, support = {}, custom_css_url = '' } = data ?? {}
|
||||
const {
|
||||
analytics = {},
|
||||
support = {},
|
||||
silence_livekit_debug_logs = false,
|
||||
custom_css_url = '',
|
||||
} = data ?? {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
@@ -23,5 +29,7 @@ export const AppInitialization = () => {
|
||||
}
|
||||
}, [custom_css_url])
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LinkButton } from '@/primitives'
|
||||
import { authUrl } from '@/features/auth'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { ProConnectButton } from './ProConnectButton'
|
||||
import { authUrl } from '@/features/auth/utils/authUrl'
|
||||
|
||||
type LoginButtonProps = {
|
||||
proConnectHint?: boolean // Hide hint in layouts where space doesn't allow it.
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import { type PostHog } from 'posthog-js'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
import posthog from 'posthog-js'
|
||||
import { ApiUser } from '@/features/auth/api/ApiUser'
|
||||
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
getPosthog().then((ph) => {
|
||||
if (ph._isIdentified()) return
|
||||
const { id, email } = data
|
||||
ph.identify(id, { email })
|
||||
})
|
||||
if (posthog._isIdentified()) return
|
||||
const { id, email } = data
|
||||
posthog.identify(id, { email })
|
||||
}
|
||||
|
||||
export const terminateAnalyticsSession = async () => {
|
||||
const ph = await getPosthog()
|
||||
if (!ph._isIdentified()) return
|
||||
ph.reset()
|
||||
export const terminateAnalyticsSession = () => {
|
||||
if (!posthog._isIdentified()) return
|
||||
posthog.reset()
|
||||
}
|
||||
|
||||
export type useAnalyticsProps = {
|
||||
@@ -33,26 +22,18 @@ export type useAnalyticsProps = {
|
||||
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !host || isDisabled) return
|
||||
getPosthog().then((ph) => {
|
||||
if (ph.__loaded) return
|
||||
ph.init(id, { api_host: host, person_profiles: 'always' })
|
||||
if (posthog.__loaded) return
|
||||
posthog.init(id, {
|
||||
api_host: host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
}, [id, host, isDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
startAnalyticsSession(user)
|
||||
}, [user])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
getPosthog().then((ph) => {
|
||||
ph.capture('$pageview')
|
||||
})
|
||||
posthog.capture('$pageview')
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
|
||||
@@ -2,7 +2,16 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
startAnalyticsSession,
|
||||
terminateAnalyticsSession,
|
||||
} from '@/features/analytics/hooks/useAnalytics'
|
||||
import {
|
||||
initializeSupportSession,
|
||||
terminateSupportSession,
|
||||
} from '@/features/support/hooks/useSupport'
|
||||
import { logoutUrl } from '../utils/logoutUrl'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
/**
|
||||
@@ -36,6 +45,19 @@ export const useUser = (
|
||||
enabled: !isConfigLoading,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.data) {
|
||||
startAnalyticsSession(query.data)
|
||||
initializeSupportSession(query.data)
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
const logout = () => {
|
||||
terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
|
||||
const isLoggedIn =
|
||||
query.status === 'success' ? query.data !== false : undefined
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
@@ -45,5 +67,6 @@ export const useUser = (
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
isLoading: query.isLoading,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useUser } from '../api/useUser'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useUser } from './api/useUser'
|
||||
export { useSyncUserPreferencesWithBackend } from './api/useSyncUserPreferencesWithBackend'
|
||||
export { authUrl } from './utils/authUrl'
|
||||
export { UserAware } from './components/UserAware'
|
||||
@@ -1,13 +0,0 @@
|
||||
import { apiUrl } from '@/api/apiUrl'
|
||||
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
|
||||
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
|
||||
const logoutUrl = () => {
|
||||
return apiUrl('/logout')
|
||||
}
|
||||
|
||||
export const logout = async () => {
|
||||
await terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { apiUrl } from '@/api/apiUrl'
|
||||
|
||||
export const logoutUrl = () => {
|
||||
return apiUrl('/logout')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authUrl } from './authUrl'
|
||||
import { authUrl } from '@/features/auth'
|
||||
|
||||
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ApiFileType,
|
||||
ApiFileUploadState,
|
||||
} from '@/features/files/api/types.ts'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
type ListFilesResponse = {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { loadUserChoices } from '@livekit/components-core'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = loadUserChoices()
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then(setLaterRoom)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Home as HomeRoute } from './routes/Home'
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DialogTrigger } from 'react-aria-components'
|
||||
import { Button } from '@/primitives'
|
||||
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { UserAware } from '@/features/auth/components/UserAware'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { generateRoomId, useCreateRoom } from '@/features/rooms'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { IntroSlider } from '../components/IntroSlider'
|
||||
import { MoreLink } from '../components/MoreLink'
|
||||
import { CreateMeetingMenu } from '../components/CreateMeetingMenu'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
@@ -141,11 +146,18 @@ const IntroText = styled('div', {
|
||||
},
|
||||
})
|
||||
|
||||
const Home = () => {
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -188,7 +200,45 @@ const Home = () => {
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<CreateMeetingMenu />
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
)}
|
||||
@@ -214,9 +264,11 @@ const Home = () => {
|
||||
<IntroSlider />
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
|
||||
@@ -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,7 +3,7 @@ import { H, P, A, Italic, Ul } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const AccessibilityRoute = () => {
|
||||
export const AccessibilityRoute = () => {
|
||||
const { t } = useTranslation('accessibility', { keyPrefix: 'accessibility' })
|
||||
|
||||
return (
|
||||
@@ -78,5 +78,3 @@ const AccessibilityRoute = () => {
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessibilityRoute
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LegalTermsRoute = () => {
|
||||
export const LegalTermsRoute = () => {
|
||||
const { t } = useTranslation('legals')
|
||||
|
||||
const indentedStyle = css({
|
||||
@@ -72,5 +72,3 @@ const LegalTermsRoute = () => {
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default LegalTermsRoute
|
||||
|
||||
@@ -12,7 +12,7 @@ const ensureArray = (value: any) => {
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const TermsOfServiceRoute = () => {
|
||||
export const TermsOfServiceRoute = () => {
|
||||
const { t } = useTranslation('termsOfService')
|
||||
|
||||
return (
|
||||
@@ -199,5 +199,3 @@ const TermsOfServiceRoute = () => {
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default TermsOfServiceRoute
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { type ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
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,4 +1,4 @@
|
||||
import type { NotificationType } from './NotificationType'
|
||||
import { NotificationType } from './NotificationType'
|
||||
|
||||
export interface NotificationPayload {
|
||||
type: NotificationType
|
||||
|
||||
@@ -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,10 +1,10 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ToastState } from '@react-stately/toast'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRef } from 'react'
|
||||
import type { ToastState } from '@react-stately/toast'
|
||||
import type { ToastData } from './ToastProvider'
|
||||
import { ToastData } from './ToastProvider'
|
||||
import type { QueuedToast } from '@react-stately/toast'
|
||||
|
||||
export const StyledToastContainer = styled('div', {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
@@ -6,7 +6,7 @@ import Source = Track.Source
|
||||
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { Text } from '@/primitives'
|
||||
import { RiMessage2Line } from '@remixicon/react'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
||||
import { ToastRegion } from './ToastRegion'
|
||||
import { Participant } from 'livekit-client'
|
||||
import type { NotificationType } from '../NotificationType'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
export interface ToastData {
|
||||
participant?: Participant
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import type { NotificationType } from '../NotificationType'
|
||||
import type { NotificationPayload } from '../NotificationPayload'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
import { NotificationPayload } from '../NotificationPayload'
|
||||
|
||||
export const useNotifyParticipants = () => {
|
||||
const room = useRoomContext()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import useSound from 'use-sound'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import type { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import type { NotificationPayload } from './NotificationPayload'
|
||||
import type { RecordingMode } from '@/features/recording'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { NotificationPayload } from './NotificationPayload'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export const showLowerHandToast = (
|
||||
participant: Participant,
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { reactionsStore } from '@/stores/reactions'
|
||||
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
|
||||
import type { Reaction } from '../types'
|
||||
import { Reaction } from '../types'
|
||||
import {
|
||||
ANIMATION_DISTANCE,
|
||||
ANIMATION_DURATION,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import type { Emoji } from '../../types'
|
||||
import { Emoji } from '../../types'
|
||||
import { useReactions } from '../../hooks/useReactions'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
|
||||