mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-30 05:38:59 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b92ae8f73 | |||
| ac2b5bd4f3 | |||
| 7c92f6054b | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 |
@@ -10,6 +10,41 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) push recordings to the owner's Drive (POC)
|
||||
|
||||
## Fixed
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) allow promoting authenticated participants
|
||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
- 📝(legal) update terms of service
|
||||
- 💄(frontend) render Avatar initials in uppercase
|
||||
- 💄(frontend) improve participant name rendering in the list
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
- 🐛(backend) preserve recording metadata when updating room access
|
||||
- 🐛(backend) allow any string as sub in the API serializer
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||
- ✨(all) allow forcing SSO display name for authenticated users
|
||||
|
||||
@@ -84,6 +84,7 @@ bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
create-docker-network \
|
||||
build \
|
||||
migrate \
|
||||
demo \
|
||||
@@ -117,11 +118,16 @@ down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
|
||||
create-docker-network: ## create the shared lasuite-network if it doesn't exist
|
||||
@docker network create lasuite-network || true
|
||||
.PHONY: create-docker-network
|
||||
|
||||
logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(MAKE) create-docker-network
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
|
||||
@@ -164,6 +164,7 @@ echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - src/agents/uv.lock"
|
||||
echo " - CHANGELOG.md"
|
||||
echo ""
|
||||
print_warning "Next steps:"
|
||||
|
||||
+25
-8
@@ -14,6 +14,9 @@ services:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -33,6 +36,13 @@ services:
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
networks:
|
||||
default:
|
||||
# The backend containers also sit on lasuite-network, where Drive's own
|
||||
# minio answers to "minio" as well. They address this one by an alias no
|
||||
# other stack uses, so the two can never race in DNS.
|
||||
aliases:
|
||||
- meet-minio
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
@@ -93,6 +103,7 @@ services:
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -109,6 +120,9 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
app:
|
||||
build:
|
||||
@@ -196,32 +210,32 @@ services:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
image: quay.io/keycloak/keycloak:26.3.2
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --proxy-headers=xforwarded
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: meet
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server
|
||||
@@ -338,3 +352,6 @@ services:
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
external: true
|
||||
|
||||
+237
-32
@@ -56,7 +56,9 @@
|
||||
"value": "meet"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
@@ -70,7 +72,9 @@
|
||||
"value": "password-e2e-chromium"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
@@ -84,7 +88,9 @@
|
||||
"value": "password-e2e-webkit"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
@@ -98,7 +104,9 @@
|
||||
"value": "password-e2e-firefox"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
@@ -118,9 +126,15 @@
|
||||
"description": "${role_default-roles}",
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"realm": ["offline_access", "uma_authorization"],
|
||||
"realm": [
|
||||
"offline_access",
|
||||
"uma_authorization"
|
||||
],
|
||||
"client": {
|
||||
"account": ["view-profile", "manage-account"]
|
||||
"account": [
|
||||
"view-profile",
|
||||
"manage-account"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "false",
|
||||
@@ -269,7 +283,9 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"realm-management": ["query-clients"]
|
||||
"realm-management": [
|
||||
"query-clients"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -292,7 +308,10 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"realm-management": ["query-users", "query-groups"]
|
||||
"realm-management": [
|
||||
"query-users",
|
||||
"query-groups"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -368,7 +387,9 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"account": ["view-consent"]
|
||||
"account": [
|
||||
"view-consent"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -400,7 +421,9 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"account": ["manage-account-links"]
|
||||
"account": [
|
||||
"manage-account-links"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -455,7 +478,9 @@
|
||||
"clientRole": "false",
|
||||
"containerId": "ccf4fd40-4286-474d-854a-4714282a8bec"
|
||||
},
|
||||
"requiredCredentials": ["password"],
|
||||
"requiredCredentials": [
|
||||
"password"
|
||||
],
|
||||
"otpPolicyType": "totp",
|
||||
"otpPolicyAlgorithm": "HmacSHA1",
|
||||
"otpPolicyInitialCounter": 0,
|
||||
@@ -463,9 +488,14 @@
|
||||
"otpPolicyLookAheadWindow": 1,
|
||||
"otpPolicyPeriod": 30,
|
||||
"otpPolicyCodeReusable": "false",
|
||||
"otpSupportedApplications": ["totpAppGoogleName", "totpAppFreeOTPName"],
|
||||
"otpSupportedApplications": [
|
||||
"totpAppGoogleName",
|
||||
"totpAppFreeOTPName"
|
||||
],
|
||||
"webAuthnPolicyRpEntityName": "keycloak",
|
||||
"webAuthnPolicySignatureAlgorithms": ["ES256"],
|
||||
"webAuthnPolicySignatureAlgorithms": [
|
||||
"ES256"
|
||||
],
|
||||
"webAuthnPolicyRpId": "",
|
||||
"webAuthnPolicyAttestationConveyancePreference": "not specified",
|
||||
"webAuthnPolicyAuthenticatorAttachment": "not specified",
|
||||
@@ -475,7 +505,9 @@
|
||||
"webAuthnPolicyAvoidSameAuthenticatorRegister": "false",
|
||||
"webAuthnPolicyAcceptableAaguids": [],
|
||||
"webAuthnPolicyPasswordlessRpEntityName": "keycloak",
|
||||
"webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"],
|
||||
"webAuthnPolicyPasswordlessSignatureAlgorithms": [
|
||||
"ES256"
|
||||
],
|
||||
"webAuthnPolicyPasswordlessRpId": "",
|
||||
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified",
|
||||
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
|
||||
@@ -487,14 +519,19 @@
|
||||
"scopeMappings": [
|
||||
{
|
||||
"clientScope": "offline_access",
|
||||
"roles": ["offline_access"]
|
||||
"roles": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopeMappings": {
|
||||
"account": [
|
||||
{
|
||||
"client": "account-console",
|
||||
"roles": ["manage-account", "view-groups"]
|
||||
"roles": [
|
||||
"manage-account",
|
||||
"view-groups"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -509,7 +546,9 @@
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": "false",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/meet/account/*"],
|
||||
"redirectUris": [
|
||||
"/realms/meet/account/*"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": "false",
|
||||
@@ -551,7 +590,9 @@
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": "false",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/meet/account/*"],
|
||||
"redirectUris": [
|
||||
"/realms/meet/account/*"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": "false",
|
||||
@@ -796,8 +837,12 @@
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": "false",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/admin/meet/console/*"],
|
||||
"webOrigins": ["+"],
|
||||
"redirectUris": [
|
||||
"/admin/meet/console/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"+"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": "false",
|
||||
"consentRequired": "false",
|
||||
@@ -845,6 +890,142 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "drive",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:3100/*",
|
||||
"http://localhost:8171/*",
|
||||
"http://localhost:8085/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3100",
|
||||
"http://localhost:8171",
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "deploycenter",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:3100/*",
|
||||
"http://localhost:8171/*",
|
||||
"http://localhost:8085/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3100",
|
||||
"http://localhost:8171",
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
@@ -1382,7 +1563,9 @@
|
||||
},
|
||||
"smtpServer": {},
|
||||
"eventsEnabled": "false",
|
||||
"eventsListeners": ["jboss-logging"],
|
||||
"eventsListeners": [
|
||||
"jboss-logging"
|
||||
],
|
||||
"enabledEventTypes": [],
|
||||
"adminEventsEnabled": "false",
|
||||
"adminEventsDetailsEnabled": "false",
|
||||
@@ -1405,7 +1588,9 @@
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"allow-default-scopes": ["true"]
|
||||
"allow-default-scopes": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1415,7 +1600,9 @@
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"max-clients": ["200"]
|
||||
"max-clients": [
|
||||
"200"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1425,7 +1612,9 @@
|
||||
"subType": "authenticated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"allow-default-scopes": ["true"]
|
||||
"allow-default-scopes": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1481,8 +1670,12 @@
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"host-sending-registration-request-must-match": ["true"],
|
||||
"client-uris-must-match": ["true"]
|
||||
"host-sending-registration-request-must-match": [
|
||||
"true"
|
||||
],
|
||||
"client-uris-must-match": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1501,7 +1694,9 @@
|
||||
"providerId": "aes-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"]
|
||||
"priority": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1510,8 +1705,12 @@
|
||||
"providerId": "hmac-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"],
|
||||
"algorithm": ["HS256"]
|
||||
"priority": [
|
||||
"100"
|
||||
],
|
||||
"algorithm": [
|
||||
"HS256"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1520,8 +1719,12 @@
|
||||
"providerId": "rsa-enc-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"],
|
||||
"algorithm": ["RSA-OAEP"]
|
||||
"priority": [
|
||||
"100"
|
||||
],
|
||||
"algorithm": [
|
||||
"RSA-OAEP"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1530,7 +1733,9 @@
|
||||
"providerId": "rsa-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"]
|
||||
"priority": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -126,6 +126,90 @@ RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
## Push recordings to Drive
|
||||
|
||||
Once a recording is over, it can be pushed to the main workspace of the user who
|
||||
started it in [Drive](https://github.com/suitenumerique/drive), on top of staying
|
||||
in the object storage. The file is streamed from the object storage to Drive: it
|
||||
is never fully held in the worker's memory nor written to its disk.
|
||||
|
||||
Drive is called as an OIDC resource server, following its
|
||||
[resource server documentation](https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Backend as Django Backend
|
||||
participant Worker as Celery Worker
|
||||
participant Storage as Object Storage
|
||||
participant Drive
|
||||
|
||||
User->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
|
||||
Backend->>Backend: Park the user's OIDC access token (encrypted)
|
||||
|
||||
Note over Backend: Recording in progress...
|
||||
|
||||
Storage->>Backend: Storage event notification
|
||||
Backend->>Worker: Schedule push_recording
|
||||
|
||||
Worker->>Drive: GET /items/ (as the user)
|
||||
Drive-->>Worker: Main workspace
|
||||
Worker->>Drive: POST /items/{workspace}/children/
|
||||
Drive-->>Worker: Item + presigned upload URL
|
||||
Worker->>Storage: GET recording (streamed)
|
||||
Worker->>Drive: PUT presigned URL (relayed chunk by chunk)
|
||||
Worker->>Drive: POST /items/{item}/upload-ended/
|
||||
Worker->>Backend: Drop the parked access token
|
||||
```
|
||||
|
||||
### Special requirements
|
||||
|
||||
- Drive configured as an OIDC resource server, accepting Meet's audience
|
||||
(`OIDC_RS_ALLOWED_AUDIENCES` must contain Meet's client id), with the `items`
|
||||
endpoint allowing the `list`, `children` and `upload_ended` actions.
|
||||
- `OIDC_STORE_ACCESS_TOKEN` enabled on Meet, along with
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY`, the Fernet key encrypting the parked token.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a proof of concept: the access token is captured when the recording
|
||||
> starts and assumed to still be valid when the recording ends. Long recordings
|
||||
> may therefore fail to be pushed. Exchanging it for a long-lived, narrowly
|
||||
> scoped token ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)) is the
|
||||
> intended follow-up.
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RECORDING_PUSH_TO_DRIVE_ENABLED** | Boolean | `False` | Enable pushing recordings to the owner's Drive. |
|
||||
| **DRIVE_API_BASE_URL** | String | `None` | Base URL of Drive's external API, e.g. `https://drive.example.com/external_api/v1.0`. |
|
||||
| **RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS** | Integer | `3600` | Lifetime of the signed URL the worker downloads the recording from. |
|
||||
| **OIDC_STORE_ACCESS_TOKEN** | Boolean | `False` | Keep the user's access token in the session, required to call Drive on their behalf. |
|
||||
| **OIDC_STORE_REFRESH_TOKEN_KEY** | Secret/File | `None` | Fernet key encrypting OIDC tokens at rest. Generate one with `Fernet.generate_key()`. |
|
||||
| **DRIVE_UPLOAD_STORAGE_NETLOC** | String | `None` | Development only: `host:port` to reach Drive's object storage at, when the domain Drive signs its upload URLs with only resolves from a browser. |
|
||||
|
||||
### Local development
|
||||
|
||||
Meet and Drive run as two separate compose projects, joined by the external
|
||||
`lasuite-network` (`make create-docker-network`). Meet's backend containers reach
|
||||
Drive's nginx at `drive-nginx:8083` and its object storage at `drive-minio:9000`.
|
||||
|
||||
On the Drive side:
|
||||
|
||||
```bash
|
||||
OIDC_RESOURCE_SERVER_ENABLED=True
|
||||
OIDC_RS_CLIENT_ID=drive
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_AUDIENCE_CLAIM=client_id
|
||||
OIDC_RS_ALLOWED_AUDIENCES=meet
|
||||
```
|
||||
|
||||
`DRIVE_UPLOAD_STORAGE_NETLOC` is needed there because Drive signs its upload URLs
|
||||
with `localhost:9100`, which does not resolve from Meet's containers. The
|
||||
presigned signature covers the `Host` header, so the backend keeps announcing the
|
||||
signed host and only swaps the address it connects to.
|
||||
|
||||
|
||||
## LiveKit Egress
|
||||
|
||||
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -344,6 +344,7 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
publiccodeYmlVersion: 0.5.0
|
||||
name: LaSuite Meet
|
||||
applicationSuite: LaSuite
|
||||
url: https://github.com/suitenumerique/meet
|
||||
releaseDate: 2026-07-22
|
||||
platforms:
|
||||
- web
|
||||
organisation:
|
||||
name: DINUM
|
||||
uri: https://numerique.gouv.fr
|
||||
fundedBy:
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: https://www.numerique.gouv.fr
|
||||
developmentStatus: stable
|
||||
softwareType: standalone/web
|
||||
intendedAudience:
|
||||
countries:
|
||||
- FR
|
||||
description:
|
||||
en:
|
||||
localisedName: LaSuite Meet
|
||||
shortDescription: "Open Source video conference solution, based on LiveKit"
|
||||
longDescription: "Open Source video conference application, based on LiveKit,
|
||||
Django and React. It is the official web video conference application of
|
||||
French Ministries."
|
||||
features:
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
legal:
|
||||
license: MIT
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Samuel Paccoud"
|
||||
email: samuel.paccoud@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
- name: "Antoine Lebaud"
|
||||
email: antoine.lebaud.ext@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- fr
|
||||
- de
|
||||
- en
|
||||
- nl
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"version": "26.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.4",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
|
||||
class IsPresentInMeeting(permissions.BasePermission):
|
||||
"""Check that the requesting user is currently connected to the meeting.
|
||||
|
||||
The requester must be session-authenticated (their DB identity is needed
|
||||
to check privileges); presence is verified against LiveKit using their
|
||||
`sub` as participant identity. Fails closed on LiveKit errors.
|
||||
"""
|
||||
|
||||
message = "You must be connected to the meeting to perform this action."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Verify the requester's identity is a participant of the room."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -299,8 +297,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||
"""Base serializer for participant management operations."""
|
||||
|
||||
participant_identity = serializers.UUIDField(
|
||||
help_text="LiveKit participant identity (UUID format)"
|
||||
participant_identity = serializers.CharField(
|
||||
help_text="LiveKit participant identity (matching the user's sub format)"
|
||||
)
|
||||
|
||||
|
||||
@@ -312,6 +310,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate an in-meeting role change (promotion/demotion) request."""
|
||||
|
||||
role = serializers.ChoiceField(
|
||||
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
|
||||
help_text="Target role. Ownership cannot be granted this way.",
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
@@ -360,6 +364,33 @@ class RoomViewSet(
|
||||
room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _park_drive_credentials(request, recording):
|
||||
"""Keep the OIDC access token needed to push the recording to Drive later.
|
||||
|
||||
Pushing happens long after this request, when the egress is over and the
|
||||
user is gone, so the token has to be parked now.
|
||||
|
||||
POC limitation: we assume the token is still valid by then. The target
|
||||
design is a token exchange (RFC 8693) performed here, to get a long-lived
|
||||
token narrowly scoped to that upload.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
|
||||
if not access_token:
|
||||
logger.warning(
|
||||
"No OIDC access token in session, recording %s will not be pushed "
|
||||
"to Drive. Is OIDC_STORE_ACCESS_TOKEN enabled?",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
recording.set_owner_access_token(access_token)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -395,6 +426,7 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
self._park_drive_credentials(request, recording)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -639,6 +671,53 @@ class RoomViewSet(
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="update-participant-role",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsPresentInMeeting,
|
||||
],
|
||||
)
|
||||
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Promote or demote a participant currently connected to the meeting.
|
||||
|
||||
Requires the requester to be session-authenticated, have privileges
|
||||
(admin/owner) on the room, and be connected to the meeting.
|
||||
|
||||
If the target participant has a user account, the role is persisted
|
||||
(`ResourceAccess`) then mirrored to their LiveKit attributes.
|
||||
|
||||
If the participant is anonymous, the promotion will fail.
|
||||
"""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.ParticipantRoleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
participant_identity = serializer.validated_data["participant_identity"]
|
||||
role = serializer.validated_data["role"]
|
||||
|
||||
if str(request.user.sub) == str(participant_identity):
|
||||
return drf_response.Response(
|
||||
{"error": "You cannot change your own role."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
result = RoomRoleService().set_participant_role(
|
||||
room=room,
|
||||
participant_identity=participant_identity,
|
||||
role=role,
|
||||
actor=request.user,
|
||||
)
|
||||
except RoomRoleError as e:
|
||||
return drf_response.Response({"error": str(e)}, status=e.status_code)
|
||||
|
||||
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-20 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-29 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0022_alter_recording_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="recording",
|
||||
name="owner_access_token",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text="Encrypted OIDC access token of the user who started the recording, used to push the recording to their Drive on their behalf. Dropped as soon as the push has been attempted.",
|
||||
null=True,
|
||||
verbose_name="Owner access token",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -613,6 +613,17 @@ class Recording(BaseModel):
|
||||
verbose_name=_("External Process ID"),
|
||||
help_text=_("ID of the external process associated with the recording."),
|
||||
)
|
||||
owner_access_token = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name=_("Owner access token"),
|
||||
help_text=_(
|
||||
"Encrypted OIDC access token of the user who started the recording, "
|
||||
"used to push the recording to their Drive on their behalf. "
|
||||
"Dropped as soon as the push has been attempted."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -715,6 +726,39 @@ class Recording(BaseModel):
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
def set_owner_access_token(self, access_token: str) -> None:
|
||||
"""Park the OIDC access token of the user who started the recording.
|
||||
|
||||
It is stored encrypted, and only long enough for the worker to push the
|
||||
recording to that user's Drive once the recording is over.
|
||||
"""
|
||||
|
||||
self.owner_access_token = utils.encrypt_secret(access_token)
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
def get_owner_access_token(self) -> Optional[str]:
|
||||
"""Return the parked OIDC access token, or None if there is none left."""
|
||||
|
||||
if not self.owner_access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
return utils.decrypt_secret(self.owner_access_token)
|
||||
except utils.SecretDecryptionError:
|
||||
logger.exception(
|
||||
"Could not decrypt the access token of recording %s", self.id
|
||||
)
|
||||
return None
|
||||
|
||||
def clear_owner_access_token(self) -> None:
|
||||
"""Drop the parked access token, it is a user credential."""
|
||||
|
||||
if self.owner_access_token is None:
|
||||
return
|
||||
|
||||
self.owner_access_token = None
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -19,6 +19,7 @@ from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.push_recording import push_recording
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +46,17 @@ class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
"""Process a recording, then push it to the owner's Drive."""
|
||||
|
||||
try:
|
||||
return self._notify_by_mode(recording)
|
||||
finally:
|
||||
# Independent from the mode: the file itself is pushed to the owner's
|
||||
# Drive, whether it is a screen recording or a transcript's audio.
|
||||
self._push_recording_to_drive(recording)
|
||||
|
||||
def _notify_by_mode(self, recording):
|
||||
"""Route a recording to the services its mode calls for."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
return self._notify_summary_service(recording)
|
||||
@@ -222,6 +233,31 @@ class NotificationService:
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _push_recording_to_drive(recording: models.Recording):
|
||||
"""Hand the recording over to the task pushing it to the owner's Drive.
|
||||
|
||||
Best effort: a failure here must not compromise the rest of the
|
||||
notification flow, the recording itself is safe in object storage.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
if not recording.owner_access_token:
|
||||
logger.warning(
|
||||
"No access token parked for recording %s, skipping the Drive push",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
push_recording.delay(str(recording.id))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"Could not schedule the Drive push of recording %s", recording.id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
@@ -9,6 +9,11 @@ from livekit import api
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -39,10 +44,15 @@ class RecordingEventsService:
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Client for La Suite Drive's external API (OIDC resource server).
|
||||
|
||||
Drive exposes `/external_api/v1.0/*` to applications holding an end user's OIDC
|
||||
access token. Uploading a file is a four step dance, documented in Drive's
|
||||
`docs/resource_server.md`:
|
||||
|
||||
1. `GET /items/` to locate the user's main workspace,
|
||||
2. `POST /items/{workspace_id}/children/` to create the file item, which returns
|
||||
a presigned upload URL (the `policy`),
|
||||
3. `PUT {policy}` to push the bytes to Drive's object storage,
|
||||
4. `POST /items/{item_id}/upload-ended/` to let Drive know the upload is over.
|
||||
|
||||
Drive never fetches a URL on our behalf, so the bytes have to transit through
|
||||
whoever holds the user's token, i.e. us.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts, in seconds. The upload one is generous: it covers a
|
||||
# whole recording being relayed to Drive's object storage.
|
||||
API_TIMEOUT = (10, 30)
|
||||
UPLOAD_TIMEOUT = (10, 300)
|
||||
|
||||
# Safety net when walking the paginated item list looking for the main workspace.
|
||||
MAX_WORKSPACE_PAGES = 10
|
||||
|
||||
|
||||
class DriveError(Exception):
|
||||
"""Raised when Drive's external API cannot fulfill a request."""
|
||||
|
||||
|
||||
class SizedStream:
|
||||
"""Read-only byte stream of a known size, suitable as a `requests` body.
|
||||
|
||||
`requests` falls back to a chunked transfer encoding when it cannot guess the
|
||||
body size upfront, which presigned S3 uploads reject. Advertising the size
|
||||
through `__len__` makes it send a plain `Content-Length` instead, while the
|
||||
underlying stream is still consumed chunk by chunk.
|
||||
"""
|
||||
|
||||
def __init__(self, stream, length: int):
|
||||
"""Wrap `stream`, whose full content is `length` bytes long."""
|
||||
self._stream = stream
|
||||
self._length = length
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the total size of the stream, in bytes."""
|
||||
return self._length
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the stream, required for `requests` to stream the body."""
|
||||
return iter(self._stream)
|
||||
|
||||
def read(self, amt=None) -> bytes:
|
||||
"""Read up to `amt` bytes from the stream."""
|
||||
return self._stream.read(amt)
|
||||
|
||||
|
||||
class DriveClient:
|
||||
"""Talk to Drive's external API on behalf of a user.
|
||||
|
||||
The client is bound to a single user access token: every call is performed
|
||||
as that user, and Drive applies its own permissions accordingly.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, *, base_url: str | None = None):
|
||||
"""Prepare a session authenticated with the user's OIDC access token."""
|
||||
|
||||
self._base_url = (base_url or settings.DRIVE_API_BASE_URL or "").rstrip("/")
|
||||
|
||||
if not self._base_url:
|
||||
raise DriveError(
|
||||
"Drive API is not configured, set DRIVE_API_BASE_URL to enable it."
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
raise DriveError("An access token is required to call Drive.")
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow use as a context manager, closing the session on exit."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close the underlying HTTP session."""
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Release the underlying HTTP session."""
|
||||
self._session.close()
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
"""Perform an authenticated call to the external API and return its body."""
|
||||
|
||||
url = f"{self._base_url}{path}"
|
||||
kwargs.setdefault("timeout", API_TIMEOUT)
|
||||
|
||||
try:
|
||||
response = self._session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError(f"Drive call failed: {method} {url}") from exc
|
||||
|
||||
if not response.content:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise DriveError(f"Drive returned a non-JSON body for {url}") from exc
|
||||
|
||||
def get_main_workspace(self) -> dict:
|
||||
"""Return the user's main workspace, the default destination for files."""
|
||||
|
||||
path = "/items/"
|
||||
|
||||
for _page in range(MAX_WORKSPACE_PAGES):
|
||||
data = self._request("GET", path) or {}
|
||||
|
||||
for item in data.get("results") or []:
|
||||
if item.get("main_workspace"):
|
||||
return item
|
||||
|
||||
next_url = data.get("next")
|
||||
if not next_url:
|
||||
break
|
||||
|
||||
# `next` is absolute; keep only what follows the API base URL.
|
||||
path = next_url[len(self._base_url) :]
|
||||
|
||||
raise DriveError("No main workspace found for this user.")
|
||||
|
||||
def create_file(self, *, parent_id: str, filename: str) -> dict:
|
||||
"""Create a file item under `parent_id` and return it.
|
||||
|
||||
The returned item carries a `policy`: the presigned URL the content has
|
||||
to be uploaded to.
|
||||
"""
|
||||
|
||||
item = self._request(
|
||||
"POST",
|
||||
f"/items/{parent_id}/children/",
|
||||
json={"type": "file", "filename": filename},
|
||||
)
|
||||
|
||||
if not item or not item.get("policy"):
|
||||
raise DriveError(
|
||||
f"Drive did not return an upload policy for file '{filename}'."
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _resolve_upload_target(policy_url: str) -> tuple[str, str | None]:
|
||||
"""Return the address to connect to, and the `Host` header to send.
|
||||
|
||||
Drive signs its upload URLs with the object storage domain meant for its
|
||||
*browser* clients, which does not necessarily resolve from here — that is
|
||||
the case in the split docker compose development setup. The presigned
|
||||
signature covers the `Host` header, so we may only swap the address we
|
||||
connect to and must keep announcing the original host.
|
||||
"""
|
||||
|
||||
override = settings.DRIVE_UPLOAD_STORAGE_NETLOC
|
||||
|
||||
if not override:
|
||||
return policy_url, None
|
||||
|
||||
parsed = urlparse(policy_url)
|
||||
return urlunparse(parsed._replace(netloc=override)), parsed.netloc
|
||||
|
||||
def upload_content(self, *, policy_url: str, stream, content_length, content_type):
|
||||
"""Push `stream` to the presigned URL, without buffering it as a whole."""
|
||||
|
||||
url, host_header = self._resolve_upload_target(policy_url)
|
||||
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(content_length),
|
||||
"x-amz-acl": "private",
|
||||
}
|
||||
|
||||
if host_header:
|
||||
headers["Host"] = host_header
|
||||
|
||||
try:
|
||||
# A bare `requests.put`, not the authenticated session: the presigned
|
||||
# URL carries its own credentials and the object storage rejects an
|
||||
# extra `Authorization` header.
|
||||
response = requests.put(
|
||||
url,
|
||||
data=SizedStream(stream, content_length),
|
||||
headers=headers,
|
||||
timeout=UPLOAD_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError("Upload to Drive's object storage failed.") from exc
|
||||
|
||||
def complete_upload(self, item_id: str) -> None:
|
||||
"""Tell Drive the upload is over, which makes the file available."""
|
||||
|
||||
self._request("POST", f"/items/{item_id}/upload-ended/", json={})
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core import models
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -177,10 +182,15 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
utils.update_room_metadata(
|
||||
room_name, {}, ["recording_mode", "recording_status"]
|
||||
RoomManagement().update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
|
||||
@@ -104,21 +104,30 @@ class LobbyService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
2. The room has TRUSTED access level and the user is authenticated
|
||||
2. The room has RESTRICTED access level and the user has any role
|
||||
|
||||
Note: Room access levels can change while participants are waiting in the lobby.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
return (
|
||||
room.is_public
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.RESTRICTED
|
||||
and user.is_authenticated
|
||||
and role is not None
|
||||
)
|
||||
)
|
||||
|
||||
def request_entry(
|
||||
@@ -144,8 +153,9 @@ class LobbyService:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
room_id = str(room.id)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
@@ -162,8 +172,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -183,8 +193,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
ListRoomsRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
async def update_metadata(
|
||||
self,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
):
|
||||
"""Merge values into a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(i.e. the Room model's UUID as a string).
|
||||
|
||||
Raises:
|
||||
RoomNotFoundException: the room does not exist in LiveKit.
|
||||
RoomManagementException: the metadata update otherwise fails.
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
||||
|
||||
if not response.rooms:
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist")
|
||||
|
||||
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
||||
|
||||
for key in remove_keys or []:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **(metadata or {})}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
metadata=json.dumps(updated_metadata),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Room role management service.
|
||||
|
||||
Single entry point for changing a user's role on a room, used by:
|
||||
- the in-meeting endpoint (promote/demote a connected participant)
|
||||
- (more to come soon)
|
||||
|
||||
`ResourceAccess` is the source of truth. The LiveKit `room_role`
|
||||
participant attribute is only a projection of it, synced best-effort.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from uuid import UUID
|
||||
|
||||
from core import models
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomRoleError(Exception):
|
||||
"""Base exception for room role management errors."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class SelfActionError(RoomRoleError):
|
||||
"""Raised when a user tries to change their own role."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class OwnerRoleError(RoomRoleError):
|
||||
"""Raised when trying to demote an owner or grant ownership."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class ParticipantNotInMeetingError(RoomRoleError):
|
||||
"""Raised when the target participant is not connected to the meeting."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
class UserNotFoundError(RoomRoleError):
|
||||
"""Raised when the target participant has no user account in database."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
|
||||
|
||||
|
||||
class RoomRoleService:
|
||||
"""Manage promotion and demotion of room co-hosts."""
|
||||
|
||||
def set_role(
|
||||
self, room: models.Room, user: models.User, role: str, actor: models.User
|
||||
):
|
||||
"""Persist `role` for `user` on `room`, idempotently and atomically.
|
||||
|
||||
Returns the up-to-date `ResourceAccess`. Never grants or removes
|
||||
ownership: granting OWNER is refused, and an existing OWNER access
|
||||
is never modified.
|
||||
"""
|
||||
|
||||
if role not in ASSIGNABLE_ROLES:
|
||||
raise OwnerRoleError("Ownership cannot be granted through this action.")
|
||||
|
||||
if actor is not None and user == actor:
|
||||
raise SelfActionError("You cannot change your own role.")
|
||||
|
||||
access, created = models.ResourceAccess.objects.get_or_create(
|
||||
resource=room,
|
||||
user=user,
|
||||
defaults={"role": role},
|
||||
)
|
||||
|
||||
if created:
|
||||
return access
|
||||
|
||||
if access.role == models.RoleChoices.OWNER:
|
||||
raise OwnerRoleError("Room owners cannot be demoted.")
|
||||
|
||||
if access.role != role:
|
||||
access.role = role
|
||||
access.save(update_fields=["role", "updated_at"])
|
||||
|
||||
return access
|
||||
|
||||
def set_participant_role(
|
||||
self,
|
||||
room: models.Room,
|
||||
participant_identity: UUID,
|
||||
role: str,
|
||||
actor: models.User,
|
||||
):
|
||||
"""Change the role of a participant currently connected to the meeting.
|
||||
|
||||
- The participant must be connected (checked against LiveKit).
|
||||
- The participant must map to a user account.
|
||||
- The role is persisted in DB then mirrored to LiveKit.
|
||||
|
||||
Returns a dict: {"role", "livekit_synced"}.
|
||||
"""
|
||||
|
||||
room_name = str(room.pk)
|
||||
participants_management = ParticipantsManagement()
|
||||
|
||||
try:
|
||||
is_in_meeting = participants_management.check_if_in_meeting(
|
||||
room_name=room_name, identity=str(participant_identity)
|
||||
)
|
||||
except ParticipantNotFoundException as e:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
) from e
|
||||
|
||||
if not is_in_meeting:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
)
|
||||
|
||||
user = models.User.objects.filter(sub=participant_identity).first()
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError(
|
||||
"This participant has no user account and cannot be assigned a role."
|
||||
)
|
||||
|
||||
# Source of truth first: even if the LiveKit sync below fails,
|
||||
# the role is real and any fresh token will carry it.
|
||||
self.set_role(room=room, user=user, role=role, actor=actor)
|
||||
|
||||
livekit_synced = self._sync_livekit_role(
|
||||
room_name=room_name,
|
||||
participant_identity=str(participant_identity),
|
||||
role=str(role),
|
||||
)
|
||||
|
||||
return {
|
||||
"role": role,
|
||||
"livekit_synced": livekit_synced,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
|
||||
"""Mirror the role to the participant's LiveKit attributes.
|
||||
|
||||
Best-effort: returns False on failure instead of raising, so callers
|
||||
can report a partial success. Re-running the action re-syncs.
|
||||
"""
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=room_name,
|
||||
identity=participant_identity,
|
||||
attributes={"room_role": role},
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
# The participant left between the presence check and the update:
|
||||
# harmless, the DB state (if any) remains authoritative.
|
||||
logger.info(
|
||||
"Participant %s left room %s before role sync",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
logger.exception(
|
||||
"Could not sync role to LiveKit for participant %s in room %s",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Asynchronous tasks of the core application.
|
||||
|
||||
Importing the task modules here is what makes Celery's `autodiscover_tasks`
|
||||
register them: it only imports the `core.tasks` package itself, never its
|
||||
submodules.
|
||||
"""
|
||||
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
__all__ = ["process_file_deletion", "push_recording"]
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Celery-optional task decorator."""
|
||||
|
||||
# ruff: noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Task pushing a finished recording to its owner's Drive."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.services.drive import API_TIMEOUT, DriveClient, DriveError
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts for the download leg, in seconds. The read one has to
|
||||
# accommodate a whole recording being relayed.
|
||||
DOWNLOAD_TIMEOUT = (API_TIMEOUT[0], 300)
|
||||
|
||||
|
||||
def _build_filename(recording: models.Recording) -> str:
|
||||
"""Return a human-readable filename for the Drive item."""
|
||||
|
||||
return (
|
||||
f"{recording.room.slug}-"
|
||||
f"{recording.created_at:%Y-%m-%d-%H-%M}."
|
||||
f"{recording.extension}"
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def push_recording(recording_id: str) -> bool:
|
||||
"""Push a recording to the main workspace of the user who started it.
|
||||
|
||||
The recording is streamed straight from object storage to Drive's presigned
|
||||
URL: it is never fully downloaded to the worker's disk or memory.
|
||||
|
||||
The access token parked when the recording started is consumed here, and
|
||||
dropped afterwards whatever the outcome — it is a user credential, and a
|
||||
replay would need a fresh one anyway.
|
||||
|
||||
Mostly taken from: https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md
|
||||
"""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(pk=recording_id)
|
||||
except models.Recording.DoesNotExist:
|
||||
logger.error(
|
||||
"Recording %s does not exist, cannot push it to Drive", recording_id
|
||||
)
|
||||
return False
|
||||
|
||||
access_token = recording.get_owner_access_token()
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"No access token stored for recording %s, cannot push it to Drive. "
|
||||
"Was OIDC_STORE_ACCESS_TOKEN enabled when the recording started?",
|
||||
recording_id,
|
||||
)
|
||||
return False
|
||||
|
||||
download_url = utils.generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
)
|
||||
filename = _build_filename(recording)
|
||||
|
||||
try:
|
||||
with DriveClient(access_token) as drive:
|
||||
workspace = drive.get_main_workspace()
|
||||
item = drive.create_file(parent_id=workspace["id"], filename=filename)
|
||||
|
||||
# The bytes are relayed chunk by chunk: the recording is never held
|
||||
# in memory as a whole.
|
||||
with requests.get(
|
||||
download_url, stream=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as download:
|
||||
download.raise_for_status()
|
||||
|
||||
content_length = download.headers.get("Content-Length")
|
||||
if content_length is None:
|
||||
raise DriveError(
|
||||
"Object storage did not return the recording size, "
|
||||
"cannot stream it to Drive."
|
||||
)
|
||||
|
||||
drive.upload_content(
|
||||
policy_url=item["policy"],
|
||||
stream=download.raw,
|
||||
content_length=int(content_length),
|
||||
content_type=download.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
)
|
||||
|
||||
drive.complete_upload(item["id"])
|
||||
|
||||
except (DriveError, requests.RequestException):
|
||||
logger.exception("Failed to push recording %s to Drive", recording_id)
|
||||
return False
|
||||
|
||||
finally:
|
||||
recording.clear_owner_access_token()
|
||||
|
||||
logger.info(
|
||||
"Recording %s pushed to Drive as '%s' (item %s)",
|
||||
recording_id,
|
||||
filename,
|
||||
item["id"],
|
||||
)
|
||||
return True
|
||||
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.netloc in ["meet-minio:9000", "minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Test pushing a recording to the owner's Drive.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import factories, models
|
||||
from core.recording.event.notification import NotificationService
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DRIVE_API = "https://drive.test/external_api/v1.0"
|
||||
DOWNLOAD_URL = "https://storage.test/recordings/recording.mp4"
|
||||
# Signed by Drive with the object storage domain meant for its browser clients.
|
||||
UPLOAD_URL = "http://drive-storage.test:9100/drive-media/item?X-Amz-Signature=deadbeef"
|
||||
INTERNAL_UPLOAD_URL = "http://drive-minio:9000/drive-media/item"
|
||||
|
||||
WORKSPACE_ID = "11111111-1111-4111-8111-111111111111"
|
||||
ITEM_ID = "22222222-2222-4222-8222-222222222222"
|
||||
|
||||
RECORDING_CONTENT = b"fake-recording-bytes"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_with_token():
|
||||
"""Return a recording carrying a parked access token."""
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
recording.set_owner_access_token("user-access-token")
|
||||
return recording
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_download_url():
|
||||
"""Avoid signing a real S3 URL, the object storage is not the point here."""
|
||||
|
||||
with mock.patch(
|
||||
"core.utils.generate_download_s3_url", return_value=DOWNLOAD_URL
|
||||
) as patched:
|
||||
yield patched
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upload():
|
||||
"""Capture what gets PUT to the presigned URL.
|
||||
|
||||
The upload sends a stream, but "responses" drains file-like bodies before
|
||||
handing the request over, so what lands here is already the bytes.
|
||||
"""
|
||||
|
||||
captured = {}
|
||||
|
||||
def callback(request):
|
||||
captured["url"] = request.url
|
||||
captured["headers"] = request.headers
|
||||
captured["body"] = request.body
|
||||
return 200, {}, ""
|
||||
|
||||
captured["callback"] = callback
|
||||
return captured
|
||||
|
||||
|
||||
def register_download(with_content_length=True):
|
||||
"""Register the object storage response holding the recording bytes."""
|
||||
|
||||
headers = (
|
||||
{"Content-Length": str(len(RECORDING_CONTENT))} if with_content_length else None
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DOWNLOAD_URL,
|
||||
body=RECORDING_CONTENT,
|
||||
status=200,
|
||||
headers=headers,
|
||||
content_type="video/mp4",
|
||||
)
|
||||
|
||||
|
||||
def register_drive(upload=None, workspaces=None):
|
||||
"""Register the Drive API calls of a successful upload."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{DRIVE_API}/items/",
|
||||
json={
|
||||
"results": workspaces
|
||||
if workspaces is not None
|
||||
else [
|
||||
{"id": "shared-workspace", "main_workspace": False},
|
||||
{"id": WORKSPACE_ID, "main_workspace": True},
|
||||
],
|
||||
"next": None,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{DRIVE_API}/items/{WORKSPACE_ID}/children/",
|
||||
json={"id": ITEM_ID, "policy": UPLOAD_URL},
|
||||
status=201,
|
||||
)
|
||||
if upload is not None:
|
||||
responses.add_callback(responses.PUT, UPLOAD_URL, callback=upload["callback"])
|
||||
responses.add_callback(
|
||||
responses.PUT, INTERNAL_UPLOAD_URL, callback=upload["callback"]
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{DRIVE_API}/items/{ITEM_ID}/upload-ended/",
|
||||
json={},
|
||||
status=200,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_uploads_to_the_main_workspace(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""The recording is created in the main workspace, uploaded, then confirmed."""
|
||||
|
||||
register_download()
|
||||
register_drive(upload=upload)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is True
|
||||
|
||||
workspaces_call, create_call, ended_call = (
|
||||
responses.calls[0].request,
|
||||
responses.calls[1].request,
|
||||
responses.calls[4].request,
|
||||
)
|
||||
|
||||
assert workspaces_call.url == f"{DRIVE_API}/items/"
|
||||
assert workspaces_call.headers["Authorization"] == "Bearer user-access-token"
|
||||
|
||||
room = recording_with_token.room
|
||||
expected_filename = (
|
||||
f"{room.slug}-{recording_with_token.created_at:%Y-%m-%d-%H-%M}.mp4"
|
||||
)
|
||||
assert expected_filename in create_call.body.decode()
|
||||
|
||||
assert upload["url"] == UPLOAD_URL
|
||||
assert upload["body"] == RECORDING_CONTENT
|
||||
assert upload["headers"]["Content-Length"] == str(len(RECORDING_CONTENT))
|
||||
assert upload["headers"]["Content-Type"] == "video/mp4"
|
||||
assert upload["headers"]["x-amz-acl"] == "private"
|
||||
# The presigned URL carries its own credentials, an extra Authorization
|
||||
# header would make the object storage reject the upload.
|
||||
assert "Authorization" not in upload["headers"]
|
||||
|
||||
assert ended_call.url == f"{DRIVE_API}/items/{ITEM_ID}/upload-ended/"
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_drops_the_access_token(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""The parked credential does not outlive the push."""
|
||||
|
||||
register_download()
|
||||
register_drive(upload=upload)
|
||||
|
||||
push_recording(str(recording_with_token.id))
|
||||
|
||||
recording_with_token.refresh_from_db()
|
||||
assert recording_with_token.owner_access_token is None
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_drops_the_access_token_on_failure(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""A failed push does not leave the credential behind either."""
|
||||
|
||||
register_download()
|
||||
register_drive(
|
||||
upload=upload, workspaces=[{"id": "shared", "main_workspace": False}]
|
||||
)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is False
|
||||
|
||||
recording_with_token.refresh_from_db()
|
||||
assert recording_with_token.owner_access_token is None
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API)
|
||||
@responses.activate
|
||||
def test_push_recording_without_parked_token():
|
||||
"""Without a token there is nobody to push on behalf of, so nothing happens."""
|
||||
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
assert push_recording(str(recording.id)) is False
|
||||
assert not responses.calls
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API)
|
||||
@responses.activate
|
||||
def test_push_recording_unknown_recording():
|
||||
"""An unknown recording is reported, not raised."""
|
||||
|
||||
assert push_recording("33333333-3333-4333-8333-333333333333") is False
|
||||
assert not responses.calls
|
||||
|
||||
|
||||
@override_settings(
|
||||
DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC="drive-minio:9000"
|
||||
)
|
||||
@responses.activate
|
||||
def test_push_recording_rewrites_the_upload_host(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""The upload reaches the internal address while announcing the signed host.
|
||||
|
||||
The presigned signature covers the Host header, so it has to stay untouched
|
||||
even when the address we connect to does not.
|
||||
"""
|
||||
|
||||
register_download()
|
||||
register_drive(upload=upload)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is True
|
||||
|
||||
assert upload["url"].startswith(INTERNAL_UPLOAD_URL)
|
||||
assert upload["headers"]["Host"] == "drive-storage.test:9100"
|
||||
assert upload["body"] == RECORDING_CONTENT
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_without_content_length(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""A size-less download cannot be relayed, and is reported as a failure."""
|
||||
|
||||
register_download(with_content_length=False)
|
||||
register_drive(upload=upload)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is False
|
||||
assert "body" not in upload
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=None)
|
||||
def test_push_recording_without_drive_configured(
|
||||
recording_with_token, mocked_download_url
|
||||
):
|
||||
"""An unconfigured Drive is reported, not raised."""
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is False
|
||||
|
||||
|
||||
def test_recording_access_token_roundtrip():
|
||||
"""The parked token is encrypted at rest and read back as-is."""
|
||||
|
||||
recording = factories.RecordingFactory()
|
||||
recording.set_owner_access_token("user-access-token")
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.owner_access_token != "user-access-token"
|
||||
assert recording.get_owner_access_token() == "user-access-token"
|
||||
|
||||
recording.clear_owner_access_token()
|
||||
recording.refresh_from_db()
|
||||
assert recording.get_owner_access_token() is None
|
||||
|
||||
|
||||
def test_recording_access_token_undecryptable():
|
||||
"""A token encrypted with another key is reported as missing, not raised."""
|
||||
|
||||
recording = factories.RecordingFactory(owner_access_token="not-a-fernet-token")
|
||||
|
||||
assert recording.get_owner_access_token() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_notify_external_services_schedules_the_push(enabled):
|
||||
"""The push is scheduled from the notification flow, when enabled."""
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
recording.set_owner_access_token("user-access-token")
|
||||
|
||||
with (
|
||||
override_settings(RECORDING_PUSH_TO_DRIVE_ENABLED=enabled),
|
||||
mock.patch("core.recording.event.notification.push_recording") as mocked_push,
|
||||
mock.patch.object(
|
||||
NotificationService, "_notify_user_by_email", return_value=True
|
||||
),
|
||||
):
|
||||
NotificationService().notify_external_services(recording)
|
||||
|
||||
assert mocked_push.delay.called is enabled
|
||||
|
||||
|
||||
def test_notify_external_services_without_parked_token():
|
||||
"""No token means nothing to push with, so no task is scheduled."""
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
|
||||
with (
|
||||
override_settings(RECORDING_PUSH_TO_DRIVE_ENABLED=True),
|
||||
mock.patch("core.recording.event.notification.push_recording") as mocked_push,
|
||||
mock.patch.object(
|
||||
NotificationService, "_notify_user_by_email", return_value=True
|
||||
),
|
||||
):
|
||||
NotificationService().notify_external_services(recording)
|
||||
|
||||
assert not mocked_push.delay.called
|
||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, mediator, mock_worker_service
|
||||
):
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(mock_recording.room.id),
|
||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||
)
|
||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
mock_update_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), admin_user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["test"]}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Must be a valid UUID." in str(response.data)
|
||||
assert "Not a valid string." in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_no_update_fields():
|
||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["invalid-uuid"]}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -278,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -313,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -330,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -355,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -401,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -418,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -493,7 +483,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -511,6 +500,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: update-participant-role.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import ResourceAccess, RoleChoices
|
||||
from ...services.participants_management import ParticipantNotFoundException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_update_participant_role_anonymous():
|
||||
"""Anonymous requesters are rejected."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_participant_role_requires_privileges():
|
||||
"""A simple member cannot promote other participants."""
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
|
||||
"""An admin who is not connected to the meeting is rejected."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
|
||||
room_name=str(room.pk), identity=str(user.sub)
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_target_self(mock_perm_pm):
|
||||
"""Requesters cannot change their own role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
client = APIClient()
|
||||
user = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": user.sub, "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "You cannot change your own role."}
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_promotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting a connected, authenticated participant persists the role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": True,
|
||||
}
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.ADMIN
|
||||
mock_sync.assert_called_once_with(
|
||||
room_name=str(room.pk),
|
||||
participant_identity=str(target.sub),
|
||||
role="administrator",
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_demotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Demoting a connected admin back to member updates the access row."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_demote_owner(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Room owners can never be demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
owner = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(owner.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "Room owners cannot be demoted."}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
|
||||
)
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_anonymous_target_is_ephemeral(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an anonymous participant should not be possible."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
anonymous_identity = uuid.uuid4()
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": anonymous_identity, "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"error": "This participant has no user account and cannot be assigned a role."
|
||||
}
|
||||
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_target_not_in_meeting(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Only connected participants can be promoted or demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
|
||||
ParticipantNotFoundException("Participant does not exist")
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_is_idempotent_and_resyncs(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_sync.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_livekit_failure_reports_partial_success(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""A LiveKit sync failure does not lose the persisted role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = False
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": False,
|
||||
}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
|
||||
"""The owner role can never be granted through this endpoint."""
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "owner"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -6,6 +6,8 @@ Test LiveKitEvents service.
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
@@ -20,8 +22,9 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -70,9 +73,11 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
# Without storage events, completion falls back to the egress event itself.
|
||||
@override_settings(RECORDING_STORAGE_EVENT_ENABLE=False)
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
@@ -86,8 +91,8 @@ def test_handle_egress_ended_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -104,9 +109,9 @@ def test_handle_egress_ended_success(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_success(
|
||||
mock_update_room_metadata, egress_status, status, service
|
||||
mock_update_metadata, egress_status, status, service
|
||||
):
|
||||
"""Should successfully update room's metadata."""
|
||||
|
||||
@@ -117,7 +122,7 @@ def test_handle_egress_updated_success(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": status}
|
||||
)
|
||||
|
||||
@@ -129,9 +134,9 @@ def test_handle_egress_updated_success(
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_room_metadata, egress_status, service
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
@@ -142,7 +147,7 @@ def test_handle_egress_updated_non_handled(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -153,9 +158,11 @@ def test_handle_egress_updated_non_handled(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
# Without storage events, completion falls back to the egress event itself.
|
||||
@override_settings(RECORDING_STORAGE_EVENT_ENABLE=False)
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop and save recording when metadata's update fails."""
|
||||
|
||||
@@ -164,7 +171,7 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -178,9 +185,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
@@ -200,15 +207,15 @@ def test_handle_egress_ended_notification_fails(
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
@@ -223,16 +230,16 @@ def test_handle_egress_ended_recording_not_found(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
@@ -244,8 +251,8 @@ def test_handle_egress_ended_recording_not_active(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -253,9 +260,9 @@ def test_handle_egress_ended_recording_not_active(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
@@ -267,16 +274,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
mock_update_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
@@ -306,7 +313,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
@@ -335,7 +342,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
@@ -345,7 +352,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
@@ -378,7 +385,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status, expected_status",
|
||||
[
|
||||
@@ -387,7 +394,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
],
|
||||
)
|
||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
@@ -424,9 +431,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_room_metadata, egress_status, service, settings
|
||||
mock_update_metadata, egress_status, service, settings
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
@@ -445,9 +452,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_room_metadata, status, service, settings
|
||||
mock_update_metadata, status, service, settings
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth."""
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
|
||||
def test_can_bypass_lobby_private_room(lobby_service):
|
||||
"""Should return False for private rooms regardless of user auth."""
|
||||
"""Should return False for private rooms regardless of user auth if role is not."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
|
||||
)
|
||||
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
||||
"""Should return True for private rooms if the user is authenticated and has any role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
request.user = UserFactory()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_participant_with_role(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a participant with a role on the room."""
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
+50
-58
@@ -17,6 +17,7 @@ from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
@@ -25,13 +26,13 @@ import botocore
|
||||
import magic
|
||||
import phonenumbers
|
||||
from asgiref.sync import async_to_sync
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -66,7 +67,7 @@ def generate_token(
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
@@ -80,7 +81,7 @@ def generate_token(
|
||||
If none, a value will be generated
|
||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||
is_admin_or_owner (bool): Whether user has admin privileges
|
||||
role (Optional[str]): Room's access role if any
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
@@ -88,6 +89,7 @@ def generate_token(
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -128,7 +130,11 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
{
|
||||
"color": color,
|
||||
"room_role": role,
|
||||
"is_authenticated": "true" if user.is_authenticated else "false",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -139,7 +145,7 @@ def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
is_admin_or_owner: bool,
|
||||
role: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
@@ -150,7 +156,7 @@ def generate_livekit_config(
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
||||
role (str): Room's access role if any
|
||||
color (Optional[str]): Optional color to associate with the participant.
|
||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
@@ -173,7 +179,7 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
),
|
||||
}
|
||||
@@ -258,57 +264,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
class MetadataUpdateException(Exception):
|
||||
"""Room's metadata update fails."""
|
||||
|
||||
|
||||
@async_to_sync
|
||||
async def update_room_metadata(
|
||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
||||
):
|
||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
||||
|
||||
Args:
|
||||
room_name: Name of the room to update
|
||||
metadata: Dictionary of metadata key-values to add/update
|
||||
remove_keys: Optional list of keys to remove from existing metadata.
|
||||
"""
|
||||
|
||||
lkapi = create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[room_name],
|
||||
)
|
||||
)
|
||||
|
||||
if not response.rooms:
|
||||
return
|
||||
|
||||
room = response.rooms[0]
|
||||
|
||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
||||
|
||||
if remove_keys:
|
||||
for key in remove_keys:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **metadata}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
raise MetadataUpdateException(
|
||||
f"Failed to update metadata for room {room_name}: {e}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
@@ -464,6 +419,43 @@ def generate_upload_policy(file):
|
||||
return policy
|
||||
|
||||
|
||||
class SecretDecryptionError(Exception):
|
||||
"""Raised when a stored secret cannot be decrypted."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_cipher_suite():
|
||||
"""Return the Fernet cipher suite used to encrypt secrets at rest.
|
||||
|
||||
Deliberately the same key as django-lasuite's OIDC token storage, so a
|
||||
deployment only has one key to provision for user credentials.
|
||||
"""
|
||||
|
||||
key = settings.OIDC_STORE_REFRESH_TOKEN_KEY
|
||||
|
||||
if not key:
|
||||
raise ImproperlyConfigured("OIDC_STORE_REFRESH_TOKEN_KEY setting is required.")
|
||||
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
"""Encrypt a secret meant to be stored at rest."""
|
||||
|
||||
return get_cipher_suite().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
"""Decrypt a secret stored by `encrypt_secret`."""
|
||||
|
||||
try:
|
||||
return get_cipher_suite().decrypt(value.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise SecretDecryptionError(
|
||||
"The stored secret could not be decrypted, was the key rotated?"
|
||||
) from exc
|
||||
|
||||
|
||||
def generate_download_s3_url(
|
||||
key: str, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
|
||||
@@ -398,6 +398,9 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
@@ -565,6 +568,20 @@ class Base(Configuration):
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Required to call other La Suite applications on behalf of the user, e.g.
|
||||
# to push a recording to their Drive.
|
||||
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Fernet key used to encrypt OIDC tokens at rest, both the refresh token
|
||||
# django-lasuite stores in the session and the access token parked on a
|
||||
# recording. Generate one with `Fernet.generate_key()`.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = SecretFileValue(
|
||||
None, environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", environ_prefix=None
|
||||
)
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
@@ -717,6 +734,29 @@ class Base(Configuration):
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Push recordings to Drive
|
||||
# Once a recording is over, it is pushed to the main workspace of the user who
|
||||
# started it, using their OIDC access token. It requires OIDC_STORE_ACCESS_TOKEN,
|
||||
# and Drive to be configured as an OIDC resource server accepting Meet's audience.
|
||||
RECORDING_PUSH_TO_DRIVE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_PUSH_TO_DRIVE_ENABLED", environ_prefix=None
|
||||
)
|
||||
# Base URL of Drive's external API, e.g. https://drive.example.com/external_api/v1.0
|
||||
DRIVE_API_BASE_URL = values.Value(
|
||||
None, environ_name="DRIVE_API_BASE_URL", environ_prefix=None
|
||||
)
|
||||
# Lifetime of the signed URL the worker downloads the recording from.
|
||||
RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS = values.PositiveIntegerValue(
|
||||
60 * 60,
|
||||
environ_name="RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Development only: host:port to reach Drive's object storage at, when the
|
||||
# domain Drive signs its upload URLs with is only resolvable from a browser.
|
||||
DRIVE_UPLOAD_STORAGE_NETLOC = values.Value(
|
||||
None, environ_name="DRIVE_UPLOAD_STORAGE_NETLOC", environ_prefix=None
|
||||
)
|
||||
|
||||
# Recording encoding options for LiveKit Egress (video composite egress only).
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+32
-26
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -15,11 +15,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -29,8 +29,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
@@ -1028,9 +1028,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.45.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||
"version": "1.46.6",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@@ -1049,12 +1049,18 @@
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.35",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -2415,9 +2421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2436,12 +2442,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.0"
|
||||
"@tanstack/query-core": "5.101.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -8119,13 +8125,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.45.8",
|
||||
"@livekit/protocol": "1.46.6",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
@@ -9130,13 +9136,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.391.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||
"version": "1.395.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.35.3",
|
||||
"@posthog/types": "^1.390.2",
|
||||
"@posthog/core": "^1.38.0",
|
||||
"@posthog/types": "^1.391.1",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -22,11 +22,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -36,8 +36,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
|
||||
@@ -114,6 +114,10 @@ const config: Config = {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
overlayIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 0.6 },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
|
||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
subtitles: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
subtitles: { width: '40px', height: '40px' },
|
||||
list: { width: '32px', height: '32px' },
|
||||
placeholder: { width: '100%', height: '100%' },
|
||||
},
|
||||
notification: {
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
true: { border: '2px solid white' },
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -44,37 +28,49 @@ const avatar = cva({
|
||||
},
|
||||
})
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return ''
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return ''
|
||||
const first = words[0].charAt(0)
|
||||
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||
return (first + second).toUpperCase()
|
||||
}
|
||||
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
name?: string
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = ({
|
||||
name,
|
||||
bgColor,
|
||||
context,
|
||||
notification,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
}}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initials = getInitials(name)
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={initials.length > 1 ? 48 : 52}
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Avatar.displayName = 'Avatar'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text } from '@/primitives'
|
||||
import { ChatMessages } from './ChatMessages'
|
||||
import { ChatTextArea } from './ChatTextArea'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const ChatContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
padding: '0 1.5rem',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const ChatMessagesContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const TextContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: 'greyscale.50',
|
||||
borderRadius: 4,
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
||||
export const Chat = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
|
||||
return (
|
||||
<ChatContainer>
|
||||
<TextContainer>
|
||||
<Text variant="sm">{t('disclaimer')}</Text>
|
||||
</TextContainer>
|
||||
<ChatMessagesContainer>
|
||||
<ChatMessages />
|
||||
</ChatMessagesContainer>
|
||||
<ChatTextArea />
|
||||
</ChatContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ChatRow } from '@/stores/chat'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ChatMessageMetadata } from './ChatMessageMedata'
|
||||
import { ChatMessageBody } from './ChatMessageBody'
|
||||
|
||||
const StyledContainer = styled('li', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
type ChatMessageProps = {
|
||||
item: ChatRow
|
||||
}
|
||||
|
||||
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
||||
const time = new Date(item.timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
return (
|
||||
<StyledContainer
|
||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||
>
|
||||
{!item.hideMetadata && (
|
||||
<ChatMessageMetadata
|
||||
timestamp={item.timestamp}
|
||||
identity={item.identity}
|
||||
/>
|
||||
)}
|
||||
<ChatMessageBody message={item.message} />
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ChatRow } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
||||
|
||||
export const ChatMessageBody = React.memo(
|
||||
({ message }: ChatMessageBodyProps) => {
|
||||
const formattedMessage = useMemo(() => {
|
||||
return formatChatMessageLinks(message)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
whiteSpace: 'pre-wrap',
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{formattedMessage}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatMessageBody.displayName = 'ChatMessageBody'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ChatRow, chatStore } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
const StyledContainer = styled('span', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
||||
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
||||
|
||||
export const ChatMessageMetadata = React.memo(
|
||||
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
||||
const time = new Date(timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
|
||||
const { names } = useSnapshot(chatStore)
|
||||
|
||||
const currentDisplayName = useMemo(() => {
|
||||
if (identity) return names[identity] ?? identity
|
||||
}, [names, identity])
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Text bold={true} variant="sm">
|
||||
{currentDisplayName}
|
||||
</Text>
|
||||
<Text variant="smNote" wrap="no">
|
||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListLayout,
|
||||
Virtualizer,
|
||||
} from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChatMessage } from './ChatMessage.tsx'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { ChatRow, chatStore } from '@/stores/chat'
|
||||
|
||||
// Estimated height of a chat entry in px. ListLayout measures the real
|
||||
// rendered height of each row; this value is only used to size the
|
||||
// scrollbar before rows have been measured.
|
||||
const ESTIMATED_ROW_HEIGHT = 56
|
||||
const BOTTOM_THRESHOLD_PX = 32
|
||||
|
||||
export const ChatMessages = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
const { rows: items } = useSnapshot(chatStore)
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const stick = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
const pin = () => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
const onScroll = () => {
|
||||
stick.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
||||
}
|
||||
const ro = new ResizeObserver(pin)
|
||||
// Sizer div = ListLayout's content size. Fires on append, and again each
|
||||
// time an estimated row height is replaced by a measured one.
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
||||
ro.observe(el)
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
pin()
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<ListBox
|
||||
ref={listRef}
|
||||
aria-label={t('messagesLabel', 'Chat messages')}
|
||||
items={items}
|
||||
selectionMode="none"
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{(item: ChatRow) => (
|
||||
<ListBoxItem style={{ width: '100%' }}>
|
||||
<ChatMessage item={item} />
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
||||
import { ref } from 'valtio'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||
import type { ChatMessage } from '@livekit/components-core'
|
||||
import {
|
||||
LocalParticipant,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from 'livekit-client'
|
||||
|
||||
export const ChatProvider = () => {
|
||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
const { isChatOpen } = useSidePanel()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
resetChatStore()
|
||||
}, [])
|
||||
|
||||
// Tigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||
// (see https://github.com/livekit/components-js/issues/1158)
|
||||
// Remove this once we refactor chat to use the new text stream approach
|
||||
const latestMessage = chatMessages.slice(-1)[0]
|
||||
if (!latestMessage) return
|
||||
const from = latestMessage.from as
|
||||
| RemoteParticipant
|
||||
| LocalParticipant
|
||||
| undefined
|
||||
|
||||
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
||||
}, [chatMessages, room])
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
||||
appendRow(chatMessages[i])
|
||||
}
|
||||
}, [chatMessages])
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.send = ref(send)
|
||||
}, [send])
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.isSending = isSending
|
||||
}, [isSending])
|
||||
|
||||
// Set the unread messages count
|
||||
useEffect(() => {
|
||||
if (chatMessages.length === 0) return
|
||||
const last = chatMessages[chatMessages.length - 1]
|
||||
if (isChatOpen) {
|
||||
lastReadMsgAt.current = last.timestamp
|
||||
chatStore.unreadMessages = 0
|
||||
return
|
||||
}
|
||||
chatStore.unreadMessages = chatMessages.filter(
|
||||
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
||||
).length
|
||||
}, [chatMessages, isChatOpen])
|
||||
|
||||
// Listen to participant name changes
|
||||
useEffect(() => {
|
||||
const setName = (p: Participant) => {
|
||||
chatStore.names[p.identity] = p.name || p.identity
|
||||
}
|
||||
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
||||
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||
|
||||
type ChatSubmitButtonProps = {
|
||||
handleSubmit: () => Promise<void>
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export const ChatSubmitButton = React.memo(
|
||||
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
return (
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="sm"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t('button.label')}
|
||||
>
|
||||
<RiSendPlane2Fill />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
||||
@@ -0,0 +1,92 @@
|
||||
import { TextArea } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
chatStore,
|
||||
clearTextAreaValue,
|
||||
persistTextAreaValue,
|
||||
} from '@/stores/chat'
|
||||
import { ChatSubmitButton } from './ChatSubmitButton'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
margin: '0.75rem 0 1.5rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: 4,
|
||||
},
|
||||
})
|
||||
|
||||
export const ChatTextArea = () => {
|
||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.focus({ preventScroll: true })
|
||||
const end = el.value.length
|
||||
el.setSelectionRange(end, end)
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = chatStore.textAreaValue
|
||||
if (!send || !text) return
|
||||
await send(text)
|
||||
inputRef?.current?.focus({ preventScroll: true })
|
||||
clearTextAreaValue()
|
||||
}, [send, inputRef])
|
||||
|
||||
const isDisabled = !textAreaValue.trim() || isSending
|
||||
|
||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||
return
|
||||
e.preventDefault()
|
||||
await handleSubmit()
|
||||
}
|
||||
|
||||
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
value={textAreaValue}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={(e) => {
|
||||
persistTextAreaValue(e.target.value)
|
||||
}}
|
||||
fieldSizing={'content'}
|
||||
style={{
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
maxHeight: '240px',
|
||||
minHeight: `34px`,
|
||||
lineHeight: 1.25,
|
||||
padding: '7px 10px',
|
||||
}}
|
||||
placeholderStyle="strong"
|
||||
spellCheck={false}
|
||||
maxLength={2000}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
aria-label={t('textArea.label')}
|
||||
/>
|
||||
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
||||
const ASPECT_RATIO = 16 / 10
|
||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||
|
||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||
|
||||
interface CarouselLayoutState {
|
||||
orientation: CarouselOrientation
|
||||
maxVisibleTiles: number
|
||||
}
|
||||
|
||||
interface CarouselLayoutObserverProps {
|
||||
asideEl: React.RefObject<HTMLDivElement>
|
||||
orientation?: CarouselOrientation
|
||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||
}
|
||||
|
||||
const CarouselLayoutObserver = ({
|
||||
orientation,
|
||||
asideEl,
|
||||
onLayoutChange,
|
||||
}: CarouselLayoutObserverProps) => {
|
||||
const { width, height } = useSize(asideEl)
|
||||
|
||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||
// container size hovers around a breakpoint. A ref (not state) because
|
||||
// updating it must not trigger a re-render.
|
||||
const prevTilesRef = React.useRef(0)
|
||||
|
||||
const carouselOrientation: CarouselOrientation =
|
||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
|
||||
const availableSpan =
|
||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||
|
||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||
|
||||
let maxVisibleTiles: number
|
||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||
// Within the dead zone: keep the previous count.
|
||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||
} else {
|
||||
maxVisibleTiles = Math.round(tilesThatFit)
|
||||
prevTilesRef.current = tilesThatFit
|
||||
}
|
||||
|
||||
// Apply cosmetic layout output straight to the DOM.
|
||||
useLayoutEffect(() => {
|
||||
const el = asideEl.current
|
||||
if (!el) return
|
||||
el.dataset.lkOrientation = carouselOrientation
|
||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||
|
||||
// Report upward only what the parent actually needs for
|
||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||
useEffect(() => {
|
||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
||||
...props
|
||||
}: CarouselLayoutProps) {
|
||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||
const { width, height } = useSize(asideEl)
|
||||
const carouselOrientation = orientation
|
||||
? orientation
|
||||
: height >= width
|
||||
? 'vertical'
|
||||
: 'horizontal'
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||
orientation: orientation ?? 'vertical',
|
||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||
})
|
||||
|
||||
const tilesThatFit =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
// Stable callback + identity check: the parent only re-renders when the
|
||||
// derived layout genuinely changed, not on every resize tick.
|
||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||
setLayout((prev) =>
|
||||
prev.orientation === next.orientation &&
|
||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}, [])
|
||||
|
||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||
maxVisibleTiles = Math.round(prevTiles)
|
||||
} else if (prevTiles !== tilesThatFit) {
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||
asideEl.current.style.setProperty(
|
||||
'--lk-max-visible-tiles',
|
||||
maxVisibleTiles.toString()
|
||||
)
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
<>
|
||||
<CarouselLayoutObserver
|
||||
asideEl={asideEl}
|
||||
orientation={orientation}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
/>
|
||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||
<aside
|
||||
key={layout.orientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ParticipantTile } from './ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||
|
||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||
@@ -10,6 +10,36 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
import { PaginationControl } from './PaginationControl'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
|
||||
interface GridLayoutObserverProps {
|
||||
gridEl: React.RefObject<HTMLDivElement>
|
||||
trackCount: number
|
||||
onMaxTilesChange: (maxTiles: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless component that runs the layout calculation in isolation and
|
||||
* reports the resulting tile capacity upward.
|
||||
*
|
||||
* `useGridLayout` re-renders its host on every layout recalculation
|
||||
* (e.g. container resizes). Rendering it in a null child means only this
|
||||
* component churns; the parent `GridLayout` re-renders solely when
|
||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||
*/
|
||||
const GridLayoutObserver = ({
|
||||
gridEl,
|
||||
trackCount,
|
||||
onMaxTilesChange,
|
||||
}: GridLayoutObserverProps) => {
|
||||
const { layout } = useGridLayout(gridEl, trackCount)
|
||||
useEffect(() => {
|
||||
onMaxTilesChange(layout.maxTiles)
|
||||
}, [onMaxTilesChange, layout.maxTiles])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
||||
* @public
|
||||
*/
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
const gridEl = useRef<HTMLDivElement>(null)
|
||||
const [maxTiles, setMaxTiles] = useState(1)
|
||||
|
||||
const elementProps = React.useMemo(
|
||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||
[props]
|
||||
)
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
const pagination = usePagination(maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
@@ -57,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
data-lk-pagination={pagination.totalPageCount > 1}
|
||||
{...elementProps}
|
||||
>
|
||||
<GridLayoutObserver
|
||||
gridEl={gridEl}
|
||||
trackCount={tracks.length}
|
||||
onMaxTilesChange={setMaxTiles}
|
||||
/>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > layout.maxTiles && (
|
||||
{tracks.length > maxTiles && (
|
||||
<>
|
||||
<PaginationIndicator
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||
|
||||
export const PinAnnouncer = () => {
|
||||
usePinAnnouncements()
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
log,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const StageLayout = () => {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ updateOnlyOn: [], onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTracks = tracks
|
||||
.filter(isTrackReference)
|
||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
|
||||
const carouselTracks = tracks.filter(
|
||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||
)
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
useEffect(() => {
|
||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||
if (
|
||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||
lastAutoFocusedScreenShareTrack.current === null
|
||||
) {
|
||||
log.debug('Auto set screen share focus:', {
|
||||
newScreenShareTrack: screenShareTracks[0],
|
||||
})
|
||||
setPinnedTrack(screenShareTracks[0])
|
||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||
} else if (
|
||||
lastAutoFocusedScreenShareTrack.current &&
|
||||
!screenShareTracks.some(
|
||||
(track) =>
|
||||
track.publication.trackSid ===
|
||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||
)
|
||||
) {
|
||||
log.debug('Auto clearing screen share focus.')
|
||||
clearPinnedTrack()
|
||||
lastAutoFocusedScreenShareTrack.current = null
|
||||
}
|
||||
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
||||
const updatedFocusTrack = tracks.find(
|
||||
(tr) =>
|
||||
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
||||
tr.source === pinnedTrackRef.source
|
||||
)
|
||||
if (
|
||||
updatedFocusTrack !== pinnedTrackRef &&
|
||||
isTrackReference(updatedFocusTrack)
|
||||
) {
|
||||
setPinnedTrack(updatedFocusTrack)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
screenShareTracks
|
||||
.map(
|
||||
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
||||
)
|
||||
.join(),
|
||||
pinnedTrackRef?.publication?.trackSid,
|
||||
tracks,
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
return (
|
||||
<>
|
||||
{!pinnedTrackRef ? (
|
||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
<FocusLayout trackRef={pinnedTrackRef} />
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const usePinAnnouncements = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const lastPinnedIdentityRef = useRef<string | null>(null)
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
if (!participant) return tRooms('participants.unknown')
|
||||
return participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
},
|
||||
[tRooms]
|
||||
)
|
||||
|
||||
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
||||
|
||||
useEffect(() => {
|
||||
// 1. unpin
|
||||
if (!pinnedIdentity) {
|
||||
const lastIdentity = lastPinnedIdentityRef.current
|
||||
if (!lastIdentity) return
|
||||
|
||||
const lastParticipant =
|
||||
room.localParticipant.identity === lastIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(lastIdentity)
|
||||
|
||||
announce(
|
||||
lastParticipant?.isLocal
|
||||
? t('self.unpin')
|
||||
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
||||
)
|
||||
|
||||
lastPinnedIdentityRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// 2. same pin → do nothing
|
||||
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
||||
|
||||
// 3. new pin
|
||||
const participant =
|
||||
room.localParticipant.identity === pinnedIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(pinnedIdentity)
|
||||
|
||||
lastPinnedIdentityRef.current = pinnedIdentity
|
||||
|
||||
announce(
|
||||
participant?.isLocal
|
||||
? t('self.pin')
|
||||
: t('pin', { name: getAnnouncementName(participant) })
|
||||
)
|
||||
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useReducer, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Tripwire for speaker promotion.
|
||||
*
|
||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||
* re-render of the host component only when an active speaker has none of
|
||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||
* state and performs the actual swap.
|
||||
*
|
||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||
*
|
||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useSpeakerPromotionTrigger(
|
||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||
) {
|
||||
const room = useRoomContext()
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||
|
||||
// Refs so the listener reads current values without re-subscribing
|
||||
// and without itself being a render dependency.
|
||||
const tilesRef = useRef(sortedTiles)
|
||||
tilesRef.current = sortedTiles
|
||||
|
||||
useEffect(() => {
|
||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||
const tiles = tilesRef.current
|
||||
const hiddenSpeakerExists = speakers.some(
|
||||
(speaker) =>
|
||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||
)
|
||||
if (hiddenSpeakerExists) {
|
||||
forceRender()
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
}
|
||||
}, [room])
|
||||
}
|
||||
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
||||
|
||||
useEffect(() => {
|
||||
const handleAttributeChanged = (
|
||||
changedAttributes: Record<string, string>,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant.isLocal || !('room_role' in changedAttributes)) return
|
||||
const newRole = changedAttributes['room_role']
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.RoleChanged,
|
||||
newRole: newRole,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
}
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
const removeParticipantNotifications = (participant: Participant) => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
|
||||
@@ -14,4 +14,5 @@ export const NotificationDuration = {
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
ROLE_CHANGED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
@@ -17,4 +17,5 @@ export enum NotificationType {
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -28,7 +28,6 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
state,
|
||||
ref
|
||||
)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
@@ -38,18 +37,13 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const pinParticipant = () => {
|
||||
layoutContext?.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<ClickableToast
|
||||
ref={ref}
|
||||
onPress={(e) => {
|
||||
pinParticipant()
|
||||
setPinnedTrack(trackReference)
|
||||
closeButtonProps.onPress?.(e)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -70,6 +71,9 @@ const renderToast = (
|
||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
|
||||
export function ToastRoleChanged({ state, ...props }: Readonly<ToastProps>) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'roleChanged' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const newRole = t(`roles.${props.toast.content.newRole}`)
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t('body', { role: newRole })}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
|
||||
+6
-5
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { LocalParticipant } from 'livekit-client'
|
||||
|
||||
export const FullScreenShareWarning = ({
|
||||
trackReference,
|
||||
@@ -17,7 +17,6 @@ export const FullScreenShareWarning = ({
|
||||
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||
|
||||
const isFullScreenSharing = useMemo(() => {
|
||||
@@ -57,8 +56,10 @@ export const FullScreenShareWarning = ({
|
||||
screenSharePreferences.enabled && isFullScreenSharing
|
||||
|
||||
const handleStopScreenShare = async () => {
|
||||
if (!localParticipant.isScreenShareEnabled) return
|
||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||
const participant = trackReference.participant
|
||||
if (!(participant instanceof LocalParticipant)) return
|
||||
if (!participant.isScreenShareEnabled) return
|
||||
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
||||
}
|
||||
|
||||
const handleDismissWarning = useCallback(() => {
|
||||
+9
-7
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const Hint = styled('div', {
|
||||
@@ -25,15 +25,17 @@ const Hint = styled('div', {
|
||||
})
|
||||
|
||||
export interface KeyboardShortcutHintProps {
|
||||
children: ReactNode
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||
* typically when an element receives keyboard focus.
|
||||
*/
|
||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return <Hint>{children}</Hint>
|
||||
}
|
||||
export const KeyboardShortcutHint = React.memo(
|
||||
({ hint }: KeyboardShortcutHintProps) => {
|
||||
return <Hint>{hint}</Hint>
|
||||
}
|
||||
)
|
||||
|
||||
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ScreenShareIcon,
|
||||
useIsEncrypted,
|
||||
} from '@livekit/components-react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||
|
||||
export const ParticipantMetadata = ({
|
||||
displayedName,
|
||||
participant,
|
||||
isScreenShare,
|
||||
}: {
|
||||
displayedName: string
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const isEncrypted = useIsEncrypted(participant)
|
||||
|
||||
return (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
||||
<RaisedHandMetadataWrapper
|
||||
participant={participant}
|
||||
enabled={!isScreenShare}
|
||||
>
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
displayedName={displayedName}
|
||||
isScreenShare={isScreenShare}
|
||||
/>
|
||||
</div>
|
||||
</RaisedHandMetadataWrapper>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { CSSProperties } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const participantNameStyles: CSSProperties = {
|
||||
paddingBottom: '0.1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const participantNameScreenShareStyles: CSSProperties = {
|
||||
...participantNameStyles,
|
||||
marginLeft: '0.4rem',
|
||||
}
|
||||
|
||||
export const ParticipantName = React.memo(
|
||||
({
|
||||
displayedName,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
displayedName?: string
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantName.displayName = 'ParticipantName'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
containerType: 'size',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledAvatarWrapper = styled('div', {
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
aspectRatio: '1 / 1',
|
||||
width: 'min(90cqmin, 160px)',
|
||||
fontSize: 'min(27cqmin, 48px)',
|
||||
'[data-lk-speaking="true"] &': {
|
||||
animation: 'pulse 1s infinite',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantPlaceholderProps = {
|
||||
color: string
|
||||
displayedNamed: string
|
||||
}
|
||||
|
||||
export const ParticipantPlaceholder = React.memo(
|
||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(color),
|
||||
[color]
|
||||
)
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<StyledAvatarWrapper>
|
||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||
</StyledAvatarWrapper>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||
+31
-103
@@ -1,39 +1,32 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
VideoTrack,
|
||||
TrackRefContext,
|
||||
ParticipantContextIfNeeded,
|
||||
useParticipantInfo,
|
||||
} from '@livekit/components-react'
|
||||
import React from 'react'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
isTrackReferencePinned,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -73,16 +66,12 @@ export const ParticipantTile: (
|
||||
ref
|
||||
) {
|
||||
const trackReference = useEnsureTrackRef(trackRef)
|
||||
|
||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||
htmlProps,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
|
||||
const handleSubscribe = React.useCallback(
|
||||
@@ -90,28 +79,25 @@ export const ParticipantTile: (
|
||||
if (
|
||||
trackReference.source &&
|
||||
!subscribed &&
|
||||
layoutContext &&
|
||||
layoutContext.pin.dispatch &&
|
||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||
layoutStore.pinnedTrackRef &&
|
||||
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
||||
) {
|
||||
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
||||
clearPinnedTrack()
|
||||
}
|
||||
},
|
||||
[trackReference, layoutContext]
|
||||
[trackReference]
|
||||
)
|
||||
|
||||
const { isHandRaised } = useRaisedHand({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const participantColor = getParticipantColor(trackReference.participant)
|
||||
|
||||
const { identity, name } = useParticipantInfo({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
const participantName = name || identity || 'Unknown'
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const interactiveProps = {
|
||||
@@ -138,7 +124,9 @@ export const ParticipantTile: (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
{trackReference.participant.isLocal && (
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
)}
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
@@ -160,76 +148,16 @@ export const ParticipantTile: (
|
||||
)}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
participant={trackReference.participant}
|
||||
color={participantColor}
|
||||
displayedNamed={participantName}
|
||||
/>
|
||||
</div>
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare
|
||||
? firstInQueue
|
||||
? '#fde047'
|
||||
: 'white'
|
||||
: undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
<ParticipantMetadata
|
||||
displayedName={participantName}
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -241,13 +169,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>
|
||||
{t('toolbarHint', {
|
||||
<KeyboardShortcutHint
|
||||
hint={t('toolbarHint', {
|
||||
shortcut: formatShortcutLabel(
|
||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||
),
|
||||
})}
|
||||
</KeyboardShortcutHint>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
||||
import {
|
||||
useRaisedHand,
|
||||
useRaisedHandPosition,
|
||||
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
|
||||
const PositionInQueue = React.memo(
|
||||
({ positionInQueue }: { positionInQueue?: number }) => {
|
||||
if (!positionInQueue) return
|
||||
return (
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PositionInQueue.displayName = 'PositionInQueue'
|
||||
|
||||
const RaisedHandActiveItem = ({
|
||||
participant,
|
||||
targetRef,
|
||||
}: {
|
||||
participant: Participant
|
||||
targetRef: RefObject<HTMLElement | null>
|
||||
}) => {
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant,
|
||||
})
|
||||
useEffect(() => {
|
||||
const el = targetRef.current
|
||||
if (!el) return
|
||||
|
||||
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
||||
el.style.color = 'black'
|
||||
|
||||
return () => {
|
||||
el.style.backgroundColor = ''
|
||||
el.style.color = ''
|
||||
}
|
||||
}, [targetRef, firstInQueue])
|
||||
|
||||
return <PositionInQueue positionInQueue={positionInQueue} />
|
||||
}
|
||||
|
||||
export const RaisedHandMetadataWrapper = ({
|
||||
participant,
|
||||
enabled = true,
|
||||
children,
|
||||
}: {
|
||||
participant: Participant
|
||||
enabled?: boolean
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const { isHandRaised } = useRaisedHand({ participant })
|
||||
const showHand = isHandRaised && enabled
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
transition: 'background 200ms ease',
|
||||
}}
|
||||
>
|
||||
{showHand && (
|
||||
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
|
||||
export const EffectsButton = React.memo(() => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||
>
|
||||
<RiImageCircleAiFill />
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
EffectsButton.displayName = 'EffectsButton'
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||
|
||||
export const FocusButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||
>
|
||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
|
||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { FocusButton } from './FocusButton'
|
||||
import { EffectsButton } from './EffectsButton'
|
||||
import { MuteButton } from './MuteButton'
|
||||
import { ZoomButton } from './ZoomButton'
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
type FadeOverlayProps = {
|
||||
children: ReactNode
|
||||
hasKeyboardFocus: boolean
|
||||
}
|
||||
|
||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
const [active, setActive] = useState(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
|
||||
const armIdleTimer = () => {
|
||||
clearIdleTimer()
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setActive(false)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
setActive(true)
|
||||
armIdleTimer()
|
||||
}
|
||||
|
||||
useEffect(() => clearIdleTimer, [])
|
||||
|
||||
const isVisible = hasKeyboardFocus || active
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
data-visible={isVisible || undefined}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={handleActivity}
|
||||
onMouseMove={handleActivity}
|
||||
onMouseLeave={() => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}}
|
||||
>
|
||||
{isVisible && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const participant = trackRef.participant
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = participant.isLocal
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
zIndex: 1,
|
||||
borderRadius: '0.25rem',
|
||||
display: 'flex',
|
||||
opacity: 0.6,
|
||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||
_hover: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<HStack gap={0.5} padding={0.5}>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
<>
|
||||
{isLocal ? (
|
||||
<EffectsButton />
|
||||
) : (
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
</FadeOverlay>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiFullscreenLine } from '@remixicon/react'
|
||||
|
||||
export const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const useParticipantRole = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const updateParticipantRole = async (
|
||||
identity: string,
|
||||
role: AssignableParticipantRole
|
||||
) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
|
||||
try {
|
||||
return fetchApi(`rooms/${data.id}/update-participant-role/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: identity,
|
||||
role: role,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { updateParticipantRole }
|
||||
}
|
||||
+14
-5
@@ -1,23 +1,22 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useLowerHandParticipants } from '@/features/rooms/api/lowerHandParticipants'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useLowerHandParticipants } from '../api/lowerHandParticipants'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||
|
||||
type LowerAllHandsButtonProps = {
|
||||
participants: Array<Participant>
|
||||
}
|
||||
|
||||
export const LowerAllHandsButton = ({
|
||||
const LowerAllHandsButtonInner = ({
|
||||
participants,
|
||||
}: LowerAllHandsButtonProps) => {
|
||||
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
if (!isAdminOrOwner) return null
|
||||
if (!participants.length) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -36,3 +35,13 @@ export const LowerAllHandsButton = ({
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const LowerAllHandsButton = ({
|
||||
participants,
|
||||
}: LowerAllHandsButtonProps) => {
|
||||
return (
|
||||
<AdminOrOwnerOnly>
|
||||
<LowerAllHandsButtonInner participants={participants} />
|
||||
</AdminOrOwnerOnly>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
|
||||
export const ParticipantName = React.memo(
|
||||
({ displayedName, isLocal }: { displayedName: string; isLocal: boolean }) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
const nameRef = useRef<HTMLParagraphElement>(null)
|
||||
const [isTruncated, setIsTruncated] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = nameRef.current
|
||||
if (!el) return
|
||||
const truncated = el.scrollWidth > el.clientWidth
|
||||
setIsTruncated((prev) => (prev === truncated ? prev : truncated))
|
||||
}, [displayedName])
|
||||
|
||||
return (
|
||||
<Text
|
||||
as="div"
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
width: 'full',
|
||||
maxWidth: 'full',
|
||||
})}
|
||||
>
|
||||
<VisualOnlyTooltip
|
||||
tooltip={displayedName}
|
||||
disabled={!isTruncated}
|
||||
className={css({ display: 'flex', minWidth: 0, flex: 1 })}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'block',
|
||||
minWidth: 0,
|
||||
})}
|
||||
ref={nameRef}
|
||||
>
|
||||
{displayedName}
|
||||
</p>
|
||||
</VisualOnlyTooltip>
|
||||
{isLocal && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantName.displayName = 'ParticipantName'
|
||||
+24
-46
@@ -5,7 +5,11 @@ import { Text } from '@/primitives/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
|
||||
import {
|
||||
getParticipantIsRoomAdmin,
|
||||
getParticipantIsRoomOwner,
|
||||
getParticipantIsRoomMember,
|
||||
} from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
@@ -16,11 +20,13 @@ import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -93,9 +99,7 @@ type ParticipantListItemProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const ParticipantListItem = ({
|
||||
participant,
|
||||
}: ParticipantListItemProps) => {
|
||||
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
return (
|
||||
@@ -108,52 +112,26 @@ export const ParticipantListItem = ({
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0">
|
||||
<div className={css({ position: 'relative', flexShrink: 0 })}>
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<PinBadge participant={participant} />
|
||||
<UnauthenticatedBadge participant={participant} />
|
||||
</div>
|
||||
<VStack gap={0} alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isLocal(participant) && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
<VStack gap={0} alignItems="start" minW="0" flex="1">
|
||||
<ParticipantName
|
||||
displayedName={name}
|
||||
isLocal={isLocal(participant)}
|
||||
/>
|
||||
<Text variant="xsNote">
|
||||
{getParticipantIsRoomOwner(participant) && t('participants.host')}
|
||||
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
|
||||
{getParticipantIsRoomMember(participant) &&
|
||||
t('participants.member')}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<MicIndicator participant={participant} />
|
||||
<ParticipantMenuButton participant={participant} />
|
||||
</HStack>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReactNode, useId, useState } from 'react'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.250',
|
||||
borderRadius: '8px',
|
||||
margin: '0 .625rem 0.9375rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Header = styled('button', {
|
||||
base: {
|
||||
minHeight: '2.5rem',
|
||||
paddingX: '1.25rem',
|
||||
paddingY: '0.5rem',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
fontSize: '1rem',
|
||||
transition: 'background 200ms',
|
||||
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
||||
_hover: { backgroundColor: 'greyscale.50' },
|
||||
},
|
||||
variants: {
|
||||
isOpen: { false: { borderRadius: '7px' } },
|
||||
},
|
||||
})
|
||||
|
||||
const Chevron = styled(RiArrowUpSLine, {
|
||||
base: { transition: 'transform 200ms', flexShrink: 0 },
|
||||
variants: {
|
||||
isOpen: { false: { transform: 'rotate(180deg)' } },
|
||||
},
|
||||
})
|
||||
|
||||
const List = styled(VStack, {
|
||||
base: {
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: 'greyscale.250',
|
||||
alignItems: 'start',
|
||||
minHeight: 0,
|
||||
flexGrow: 1,
|
||||
paddingY: '0.5rem',
|
||||
paddingX: '1rem',
|
||||
gap: 0,
|
||||
},
|
||||
})
|
||||
|
||||
export type ParticipantsCollapsibleSectionProps = {
|
||||
heading: string
|
||||
count: number
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ParticipantsCollapsibleSection = ({
|
||||
heading,
|
||||
count,
|
||||
action,
|
||||
children,
|
||||
}: ParticipantsCollapsibleSectionProps) => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const listId = useId()
|
||||
return (
|
||||
<Container>
|
||||
<Header
|
||||
type="button"
|
||||
isOpen={isOpen}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listId}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<HStack justify="space-between" width="100%">
|
||||
<span>{heading}</span>
|
||||
<span>{count}</span>
|
||||
</HStack>
|
||||
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
||||
</Header>
|
||||
{isOpen && (
|
||||
<List id={listId} role="list">
|
||||
{action}
|
||||
{children}
|
||||
</List>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { srOnly } from '@/styles/a11y'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiInfinityLine } from '@remixicon/react'
|
||||
|
||||
const badgeStyles = css({
|
||||
position: 'absolute',
|
||||
top: '-.25rem',
|
||||
right: '-.25rem',
|
||||
width: '1.25rem',
|
||||
height: '1.25rem',
|
||||
backgroundColor: 'gray',
|
||||
borderRadius: '50%',
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
zIndex: 1,
|
||||
userSelect: 'none',
|
||||
})
|
||||
|
||||
/**
|
||||
* Isolated so participant join/leave events only re-render the badge,
|
||||
* not the button, tooltip or shortcut registration.
|
||||
*/
|
||||
export const ParticipantsCount = React.memo(
|
||||
({ describedById }: { describedById: string }) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'controls.participants',
|
||||
})
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [],
|
||||
})
|
||||
const count = (remoteParticipants?.length ?? 0) + 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<span id={describedById} className={srOnly}>
|
||||
{t('count', { count })}
|
||||
</span>
|
||||
<div className={badgeStyles} aria-hidden>
|
||||
{count < 100 ? count : <RiInfinityLine size={10} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
ParticipantsCount.displayName = 'ParticipantsCount'
|
||||
@@ -0,0 +1,103 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
useRemoteParticipants,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantRow } from './ParticipantRow'
|
||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
||||
import { RaisedHandRow } from './RaisedHandRow'
|
||||
|
||||
const JoinedParticipantsSections = () => {
|
||||
const room = useRoomContext()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
|
||||
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
||||
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
||||
// are room-wide and fire for the local participant too, so this hook re-emits
|
||||
// (and we re-render) on local changes, despite its name.
|
||||
//
|
||||
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
||||
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
||||
// local events waking this component up, raising your own hand would not
|
||||
// update the UI. Do not "optimise" these away.
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [
|
||||
RoomEvent.ParticipantNameChanged,
|
||||
RoomEvent.ParticipantAttributesChanged,
|
||||
],
|
||||
})
|
||||
|
||||
const allParticipants = [
|
||||
room.localParticipant,
|
||||
...[...remoteParticipants].sort((a, b) =>
|
||||
(a.name || a.identity).localeCompare(b.name || b.identity)
|
||||
),
|
||||
]
|
||||
|
||||
const participantsWithRaisedHands = allParticipants
|
||||
.filter((p) => !!p.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
||||
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
||||
return (
|
||||
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
||||
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{participantsWithRaisedHands.length > 0 && (
|
||||
<ParticipantsCollapsibleSection
|
||||
heading={t('raisedHands')}
|
||||
count={participantsWithRaisedHands.length}
|
||||
action={
|
||||
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
||||
}
|
||||
>
|
||||
{participantsWithRaisedHands.map((p) => (
|
||||
<RaisedHandRow key={p.identity} participant={p} />
|
||||
))}
|
||||
</ParticipantsCollapsibleSection>
|
||||
)}
|
||||
<ParticipantsCollapsibleSection
|
||||
heading={t('contributors')}
|
||||
count={allParticipants.length}
|
||||
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
||||
>
|
||||
{allParticipants.map((p) => (
|
||||
<ParticipantRow key={p.identity} participant={p} />
|
||||
))}
|
||||
</ParticipantsCollapsibleSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ParticipantsList = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
|
||||
return (
|
||||
<Div overflowY="scroll">
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'greyscale.600',
|
||||
padding: '0 1.5rem',
|
||||
marginBottom: '0.83em',
|
||||
})}
|
||||
>
|
||||
{t('subheading').toUpperCase()}
|
||||
</H>
|
||||
<WaitingParticipantsSection />
|
||||
<JoinedParticipantsSections />
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { RiPushpin2Fill } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { isEqualTrackRef } from '@livekit/components-core'
|
||||
import Source = Track.Source
|
||||
|
||||
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
||||
const cameraTrackRef = useMemo(() => {
|
||||
return {
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
}, [participant])
|
||||
|
||||
const screenShareTrackRef = useMemo(() => {
|
||||
return {
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(Source.ScreenShare),
|
||||
source: Source.ScreenShare,
|
||||
}
|
||||
}, [participant])
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const inFocus =
|
||||
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
||||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
||||
|
||||
if (!inFocus) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '18px',
|
||||
width: '18px',
|
||||
borderRadius: '100%',
|
||||
background: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '-2px',
|
||||
right: '-4px',
|
||||
})}
|
||||
>
|
||||
<RiPushpin2Fill size={14} aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
const ActionButton = ({
|
||||
participant,
|
||||
name,
|
||||
}: {
|
||||
participant: Participant
|
||||
name: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||
|
||||
return (
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
onPress={() => lowerHandParticipant(participant)}
|
||||
aria-label={t('participants.lowerParticipantHand', { name })}
|
||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||
data-attr="participants-lower-hand"
|
||||
>
|
||||
<RiHand />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type HandRaisedListItemProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
const name = participant.name || participant.identity
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
justify="space-between"
|
||||
key={participant.identity}
|
||||
id={participant.identity}
|
||||
className={css({
|
||||
padding: '0.25rem 0',
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0" overflow="hidden">
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Participant } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipantAttribute } from '@livekit/components-react'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const UnauthenticatedBadge = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'participants.unauthenticated',
|
||||
})
|
||||
const is_authenticated = useParticipantAttribute('is_authenticated', {
|
||||
participant,
|
||||
})
|
||||
|
||||
if (is_authenticated == 'true') return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '18px',
|
||||
width: '18px',
|
||||
borderRadius: '100%',
|
||||
background: 'orange.200',
|
||||
color: 'orange.800',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '-2px',
|
||||
right: '-4px',
|
||||
})}
|
||||
>
|
||||
<VisualOnlyTooltip tooltip={t('badge')}>
|
||||
<RiErrorWarningFill size={14} aria-hidden />
|
||||
</VisualOnlyTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+7
-37
@@ -1,12 +1,13 @@
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { Button } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export const WaitingParticipantListItem = ({
|
||||
export const WaitingParticipantRow = ({
|
||||
participant,
|
||||
onAction,
|
||||
}: {
|
||||
@@ -26,42 +27,11 @@ export const WaitingParticipantListItem = ({
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0">
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</span>
|
||||
</Text>
|
||||
<ParticipantName displayedName={participant.username} isLocal={false} />
|
||||
</HStack>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({
|
||||
flexShrink: '0',
|
||||
})}
|
||||
>
|
||||
<HStack gap="0.25rem" flexShrink={0}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
||||
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
||||
|
||||
export const WaitingParticipantsSection = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
||||
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
if (waitingParticipants?.length == 0) return
|
||||
|
||||
return (
|
||||
<ParticipantsCollapsibleSection
|
||||
heading={t('title')}
|
||||
count={waitingParticipants.length}
|
||||
>
|
||||
{waitingParticipants.map((p) => (
|
||||
<WaitingParticipantRow
|
||||
key={p.id}
|
||||
participant={p}
|
||||
onAction={handleParticipantEntry}
|
||||
/>
|
||||
))}
|
||||
</ParticipantsCollapsibleSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Menu as RACMenu } from 'react-aria-components'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||
import { PromoteMenuItem } from './items/PromoteMenuItem'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useParticipantAttributes } from '@livekit/components-react'
|
||||
|
||||
export const ParticipantMenu = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { attributes } = useParticipantAttributes({ participant })
|
||||
|
||||
const isAdmin = attributes?.room_role === 'administrator'
|
||||
const isOwner = attributes?.room_role === 'owner'
|
||||
const isAuthenticated = attributes?.is_authenticated == 'true'
|
||||
|
||||
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<PinMenuItem participant={participant} />
|
||||
{canManage && (
|
||||
<RemoveMenuItem
|
||||
identity={participant.identity}
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
{canManage && isAuthenticated && (
|
||||
<PromoteMenuItem
|
||||
identity={participant.identity}
|
||||
isAdmin={isAdmin}
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
-3
@@ -1,7 +1,6 @@
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { RiMore2Fill } from '@remixicon/react'
|
||||
import { ParticipantMenu } from './ParticipantMenu'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
if (!isAdminOrOwner) return null
|
||||
return (
|
||||
<Menu>
|
||||
<Button
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user