Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df901b9d6 | |||
| c09c440631 | |||
| cd7ce77074 | |||
| 6d3c26419d | |||
| 9dbc38984e | |||
| 1bd5a294e4 | |||
| 4d98ed4977 | |||
| bf32c073c6 | |||
| 4f2c4bfaf9 | |||
| dacf705329 | |||
| 8296738347 |
@@ -10,16 +10,25 @@ and this project adheres to
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
|
||||
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
||||
- 💄(spinner) enforce spinner height #1183
|
||||
- 💄(custom-background) add upload indicator with preview #1183
|
||||
- ♿️(backend) improve logo accessibility in recording email notification #1092
|
||||
- ♿️(summary) improve accessibility of transcription download link #1187
|
||||
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
|
||||
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
|
||||
- ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
|
||||
- ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
|
||||
- ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) disable personal custom background while deleting #1183
|
||||
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
||||
- 🐛(frontend) fix device selection not applying during conference #1156
|
||||
|
||||
## [1.11.0] - 2026-03-19
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
|
||||
@@ -113,6 +113,7 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
# Recording Delegate — Design Spec
|
||||
|
||||
## Problem
|
||||
|
||||
When a meeting organizer is absent, no one can start recording or transcription because only room admins/owners have that permission. This is a common scenario (secretary creates meetings for executives, organizer can't attend, etc.).
|
||||
|
||||
The PR #794 proposed opening recording permissions to all authenticated users, but this doesn't fit multi-tenant deployments where authenticated users may belong to different organizations.
|
||||
|
||||
## Solution
|
||||
|
||||
A lightweight **Recording Delegate** system that lets admins/owners grant recording-specific rights to individual users, with a request/approve flow for live meetings and auto-approval when no admin is present.
|
||||
|
||||
## Current State
|
||||
|
||||
### Backend
|
||||
|
||||
- `HasPrivilegesOnRoom` permission class is used on `start-recording` and `stop-recording` actions in `RoomViewSet` (viewsets.py:299, 349). This checks `is_administrator_or_owner()`.
|
||||
- `ResourceAccess` model with roles: `owner`, `administrator`, `member`.
|
||||
- No backend-to-client DataChannel messaging exists. The backend uses the LiveKit Server SDK only for webhooks, mute/remove/update participant operations via `ParticipantsManagement` service. There is no `ListParticipants` or `SendData` call.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `NoAccessView` component already includes a `RequestRecording` button and a `handleRequest` prop.
|
||||
- `ScreenRecordingSidePanel` already sends a `ScreenRecordingRequested` notification via DataChannel (client-to-client).
|
||||
- `useHasRecordingAccess` hook checks `useIsAdminOrOwner()` to determine recording access.
|
||||
- All DataChannel notifications are sent client-side via `useNotifyParticipants` → `room.localParticipant.publishData()`.
|
||||
|
||||
### What needs to change
|
||||
|
||||
- Replace `HasPrivilegesOnRoom` with `HasRecordingPermission` on recording endpoints (also check delegate status).
|
||||
- Update `useHasRecordingAccess` to also check delegate status.
|
||||
- Extend the existing `NoAccessView` request flow with backend-backed approval (currently it only sends a client-side notification with no persistence).
|
||||
- Add `ListParticipants` capability to `ParticipantsManagement` service.
|
||||
- Add backend-to-client notification capability via LiveKit Server SDK `RoomService.send_data()`.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Separate from the role system**: `RecordingDelegate` is a standalone model, not a new `RoleChoices` entry. This avoids polluting the existing owner/admin/member hierarchy and is easy to remove when a more advanced multi-admin system is built.
|
||||
- **Session or permanent**: the admin choosing to grant rights decides whether the delegation is for the current session only or permanent.
|
||||
- **Auto-approve for authenticated users**: when no admin/owner is present in the room, an authenticated user's request is auto-approved after 30s. This covers the "absent organizer" scenario without opening permissions globally.
|
||||
- **Notifications are hybrid**: client-to-client for immediate UX feedback (request/grant/revoke), backend-to-client for auto-approve (only the backend knows when the 30s timer fires).
|
||||
|
||||
## Data Model
|
||||
|
||||
### RecordingDelegate
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | UUID (PK) | Primary key |
|
||||
| `room` | FK → Room | The room this delegation applies to |
|
||||
| `user` | FK → User (CASCADE) | The delegated user |
|
||||
| `status` | CharField | `pending` or `approved` |
|
||||
| `is_permanent` | Boolean (default=False) | False = session-only, True = persists across meetings |
|
||||
| `granted_by` | FK → User (nullable, SET_NULL) | Who granted the rights. Null = auto-approved |
|
||||
| `created_at` | DateTime (auto) | Timestamp |
|
||||
|
||||
**Constraints:**
|
||||
- Unique together: `(room, user)`
|
||||
|
||||
The `status` field tracks pending requests in the database (not just Redis), so the `/approve/` endpoint can look up what it's approving, and the GET list can show pending requests to admins.
|
||||
|
||||
### Permission check
|
||||
|
||||
`HasRecordingPermission` replaces `HasPrivilegesOnRoom` on `start-recording` and `stop-recording` actions. It checks in order:
|
||||
1. User is admin/owner of the room → allowed
|
||||
2. User has a `RecordingDelegate` entry with `status=approved` for this room → allowed
|
||||
3. Otherwise → denied
|
||||
|
||||
Delegates can both start AND stop recordings (a delegate who starts a recording can stop it).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints nested under `/api/v1.0/rooms/{room_id}/recording-delegates/`.
|
||||
|
||||
| Method | Path | Permission | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/` | Admin/Owner | List delegates for this room (includes pending) |
|
||||
| `POST` | `/` | Admin/Owner | Grant recording rights (direct, status=approved) |
|
||||
| `DELETE` | `/{id}/` | Admin/Owner | Revoke a delegate |
|
||||
| `POST` | `/request/` | Authenticated | Request recording rights (creates status=pending) |
|
||||
| `POST` | `/{id}/approve/` | Admin/Owner | Approve a pending request |
|
||||
| `POST` | `/{id}/reject/` | Admin/Owner | Reject a pending request (deletes the entry) |
|
||||
|
||||
### Payloads
|
||||
|
||||
**POST (grant):**
|
||||
```json
|
||||
{
|
||||
"user": "uuid",
|
||||
"is_permanent": false
|
||||
}
|
||||
```
|
||||
|
||||
**POST approve:**
|
||||
```json
|
||||
{
|
||||
"is_permanent": false
|
||||
}
|
||||
```
|
||||
|
||||
**POST (request):**
|
||||
No body needed — user is derived from `request.user`.
|
||||
|
||||
**GET (list) response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "delegate-uuid",
|
||||
"user": { "id": "user-uuid", "name": "Jean Dupont" },
|
||||
"status": "approved",
|
||||
"is_permanent": true,
|
||||
"granted_by": { "id": "admin-uuid", "name": "Marie Martin" },
|
||||
"created_at": "2026-03-20T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## New Backend Infrastructure
|
||||
|
||||
### LiveKit ListParticipants
|
||||
|
||||
Add a `list_participants(room_name)` method to `ParticipantsManagement` service using `livekit.api.RoomService.list_participants()`. This returns the list of currently connected participants with their identity (which maps to the user ID set when generating the LiveKit token).
|
||||
|
||||
### LiveKit SendData (backend → client)
|
||||
|
||||
Add a `send_data(room_name, data, participant_identities)` method to `ParticipantsManagement` service using `livekit.api.RoomService.send_data()`. This is needed for the auto-approve flow where the backend must notify the requester after the Celery timer fires.
|
||||
|
||||
### Participant identity mapping
|
||||
|
||||
LiveKit participant `identity` is set to the Django user's UUID string when the LiveKit token is generated. The `list_participants` response provides these identities, which can be directly matched against `ResourceAccess.user_id` to determine which participants are admins/owners.
|
||||
|
||||
## Delegation Flows
|
||||
|
||||
### Flow 1: Push by admin (direct grant)
|
||||
|
||||
1. Admin clicks "Grant recording rights" on a participant
|
||||
2. `POST /recording-delegates/` with `{ user, is_permanent }`
|
||||
3. `RecordingDelegate` created with `status=approved, granted_by=admin`
|
||||
4. Admin's frontend sends DataChannel notification to participant: `RecordingRightsGranted`
|
||||
5. Participant sees recording buttons appear
|
||||
|
||||
### Flow 2: Request by participant
|
||||
|
||||
1. Participant clicks "Request recording rights"
|
||||
2. `POST /recording-delegates/request/`
|
||||
3. Backend creates `RecordingDelegate` with `status=pending`
|
||||
4. Backend calls `list_participants` and cross-references with `ResourceAccess` to check admin presence
|
||||
|
||||
**Case A — Admin present:**
|
||||
5. Requester's frontend sends DataChannel notification to admins: `RecordingRightsRequested` (with delegate ID and user info)
|
||||
6. Admin sees popup: "X requests recording rights" [Session only] [Permanent] [Reject]
|
||||
7. Admin clicks → `POST /recording-delegates/{id}/approve/` or `/{id}/reject/`
|
||||
8. Backend updates `RecordingDelegate` status to `approved` (or deletes on reject)
|
||||
9. Admin's frontend sends DataChannel notification to requester: `RecordingRightsGranted` or `RecordingRightsRejected`
|
||||
|
||||
**Case B — No admin present:**
|
||||
5. Backend schedules a Celery task with `countdown=30` seconds, storing the task ID in cache as `auto_approve:{delegate_id}`
|
||||
6. API response includes `auto_approve_seconds: 30`
|
||||
7. Frontend shows countdown: "No admin present. Auto-approval in 30s..."
|
||||
8. After 30s, Celery task fires:
|
||||
- Re-checks the `RecordingDelegate` still exists and is still `pending` (requester may have left)
|
||||
- Re-checks no admin is present via `list_participants`
|
||||
- If both conditions met: updates to `status=approved, granted_by=null, is_permanent=False`
|
||||
- Sends notification via `RoomService.send_data()`: `RecordingRightsGranted`
|
||||
- If an admin is now present: does nothing (admin will handle via Case A)
|
||||
9. If an admin connects during the 30s:
|
||||
- Admin's frontend fetches pending requests via `GET /recording-delegates/?status=pending`
|
||||
- Admin sees and handles the request (Case A flow)
|
||||
- When admin approves/rejects, the `status` changes and the Celery task's re-check at step 8 will find it's no longer `pending` → no-op
|
||||
|
||||
### Flow 3: Pre-meeting
|
||||
|
||||
1. Owner/admin goes to room management page
|
||||
2. Searches for users and adds them as recording delegates
|
||||
3. `POST /recording-delegates/` with `{ user, is_permanent: true }`
|
||||
|
||||
### Revocation
|
||||
|
||||
1. Admin/owner clicks revoke on a delegate
|
||||
2. `DELETE /recording-delegates/{id}/`
|
||||
3. Admin's frontend sends DataChannel notification to participant: `RecordingRightsRevoked`
|
||||
4. Recording buttons disappear in real-time
|
||||
5. Any active recording started by this delegate continues to completion
|
||||
|
||||
## Real-time Communication
|
||||
|
||||
### Notification types
|
||||
|
||||
| Type | Mechanism | Sent by | Sent to | Trigger |
|
||||
|---|---|---|---|---|
|
||||
| `RecordingRightsRequested` | DataChannel (client) | Requester's browser | Admins in room | Participant requests rights |
|
||||
| `RecordingRightsGranted` | DataChannel (client) or SendData (backend for auto-approve) | Admin's browser / backend | Requester | Approved or auto-approved |
|
||||
| `RecordingRightsRejected` | DataChannel (client) | Admin's browser | Requester | Rejected |
|
||||
| `RecordingRightsRevoked` | DataChannel (client) | Admin's browser | The delegate | Admin revokes rights |
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### Participant side (authenticated, non-admin)
|
||||
|
||||
Extend the existing `NoAccessView` component in `ScreenRecordingSidePanel`. The existing `RequestRecording` button and `handleRequest` prop are reused but connected to the new backend API instead of the current client-only notification.
|
||||
|
||||
States: `idle` → `pending` (with 30s countdown if auto-approve) → `granted` / `rejected`
|
||||
|
||||
Once `granted`: standard start/stop recording buttons appear.
|
||||
|
||||
### Admin side
|
||||
|
||||
- **Toast/popup** on incoming `RecordingRightsRequested` notification: "X requests recording rights" with actions [Session only] [Permanent] [Reject]
|
||||
- **Participant context menu**: "Grant recording rights" → sub-menu [Session] [Permanent]
|
||||
- On room join, fetch pending requests via `GET /recording-delegates/?status=pending` to catch requests made before the admin connected
|
||||
|
||||
### Room management page (pre-meeting)
|
||||
|
||||
New "Recording Delegation" section in the Admin panel:
|
||||
- User search field + list of current delegates with revoke button
|
||||
- Only visible to admin/owner
|
||||
|
||||
### Hooks
|
||||
|
||||
**`useRecordingDelegate(roomId)`**
|
||||
```
|
||||
→ { isDelegate, requestRights(), pendingRequest, countdown }
|
||||
```
|
||||
|
||||
**Update `useHasRecordingAccess`** to also return `true` when the user is a delegate (`status=approved`).
|
||||
|
||||
## Cleanup
|
||||
|
||||
### Definition of "session"
|
||||
|
||||
A session corresponds to a LiveKit room lifecycle (first participant joins → last participant leaves). Non-permanent delegates are cleaned up when the room ends, with a **5-minute grace period** to handle brief disconnections (all participants drop and reconnect quickly).
|
||||
|
||||
### Primary: LiveKit webhook `room_finished`
|
||||
|
||||
When `room_finished` fires, schedule a Celery task with `countdown=300` (5 minutes). When the task runs:
|
||||
- Check if the room is still empty via `list_participants`
|
||||
- If empty: delete all `RecordingDelegate` entries with `is_permanent=False` for that room
|
||||
- If participants are back: do nothing (new session started)
|
||||
|
||||
### Safety net: Celery periodic task
|
||||
|
||||
Every 6 hours, delete non-permanent delegates with `created_at` older than 24h. Covers missed webhooks. The 24h window is generous enough to cover multi-hour meetings.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Delegate starts recording then is revoked | Active recording continues. Revocation prevents new recordings. |
|
||||
| Two participants request simultaneously (auto-approve) | Independent requests. Both get rights after 30s. |
|
||||
| Admin arrives during 30s countdown | Admin fetches pending requests on join. Celery task re-checks status before approving — if admin already handled it, task is a no-op. |
|
||||
| Requester leaves room during countdown | Frontend does not cancel the pending — Celery task re-checks the delegate still exists. If the requester deleted their request on leave, task is a no-op. |
|
||||
| Permanent delegate's user deleted | FK CASCADE removes the delegate entry. |
|
||||
| Duplicate request by same user | Unique constraint `(room, user)` prevents duplicates. Returns 200 with existing delegate if already exists. |
|
||||
| Brief room-empty gap (all disconnect/reconnect) | 5-minute grace period on `room_finished` prevents premature cleanup. |
|
||||
|
||||
## Security
|
||||
|
||||
- **Authentication required**: `IsAuthenticated` on `/request/` endpoint
|
||||
- **Rate limiting**: `SessionExchangeAnonRateThrottle`-style throttle on `/request/` — 5 requests/min per user per room. Returns HTTP 429 when exceeded.
|
||||
- **Audit trail**: `granted_by` field traces who granted (null = auto-approved), `created_at` for timing
|
||||
- **No anonymous auto-approve**: only authenticated users can trigger auto-approval
|
||||
- **Celery task safety**: auto-approve task re-checks both `status=pending` and admin absence before granting — no race condition
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend (pytest)
|
||||
|
||||
**Model:**
|
||||
- CRUD operations on `RecordingDelegate`
|
||||
- Unique constraint `(room, user)` enforced
|
||||
- CASCADE delete on user/room deletion
|
||||
- Status transitions: pending → approved, pending → deleted (reject)
|
||||
|
||||
**Permissions:**
|
||||
- Admin/owner can start-recording (unchanged)
|
||||
- Delegate (status=approved) can start-recording → 201
|
||||
- Delegate (status=pending) cannot start-recording → 403
|
||||
- Authenticated non-delegate → 403
|
||||
- Anonymous → 401
|
||||
- Revoked delegate → 403
|
||||
- Delegate can stop-recording they started → 200
|
||||
|
||||
**API:**
|
||||
- POST delegate: admin → 201, non-admin → 403
|
||||
- DELETE delegate: admin → 204, non-admin → 403
|
||||
- POST request: authenticated → 201 (pending created), anonymous → 401, existing delegate → 200
|
||||
- POST approve: admin → 200 (status updated), non-admin → 403
|
||||
- POST reject: admin → 200 (delegate deleted), non-admin → 403
|
||||
- GET list: admin sees pending + approved, non-admin → 403
|
||||
|
||||
**Auto-approve:**
|
||||
- Request with no admin present → Celery task scheduled
|
||||
- After 30s, task fires → delegate approved with `granted_by=null`
|
||||
- Admin present when task fires → task is no-op
|
||||
- Delegate no longer pending when task fires → task is no-op
|
||||
- Requester deleted request → task is no-op
|
||||
|
||||
**Cleanup:**
|
||||
- Webhook `room_finished` + 5min grace → non-permanent delegates deleted
|
||||
- Room not empty after grace period → delegates preserved
|
||||
- Permanent delegates → preserved
|
||||
- Celery periodic task → delegates older than 24h deleted
|
||||
|
||||
**New infrastructure:**
|
||||
- `list_participants` returns correct participant identities
|
||||
- `send_data` delivers notification to specific participant
|
||||
- Participant identity maps to user UUID
|
||||
|
||||
### Frontend (vitest)
|
||||
|
||||
- `useRecordingDelegate`: states idle/pending/granted/rejected
|
||||
- `useHasRecordingAccess`: returns true for delegates
|
||||
- Request button visible for authenticated non-admin, hidden for anonymous
|
||||
- Recording buttons visible after granted
|
||||
- Admin popup: all 3 actions work (session/permanent/reject)
|
||||
- Admin fetches pending requests on room join
|
||||
- 30s countdown displayed correctly during auto-approve
|
||||
- Revocation removes recording buttons in real-time
|
||||
@@ -452,7 +452,11 @@ class Base(Configuration):
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_ENGINE = values.Value(
|
||||
default="django.contrib.sessions.backends.cache",
|
||||
environ_name="SESSION_ENGINE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
||||
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
||||
|
||||
Generated
+17
-13
@@ -6269,10 +6269,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -7158,10 +7161,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
|
||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
||||
"dev": true
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.3",
|
||||
@@ -7628,9 +7632,9 @@
|
||||
"integrity": "sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg=="
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
|
||||
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11107,9 +11111,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -31,6 +31,8 @@ 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'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
@@ -237,7 +239,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
<KeyboardShortcutHint>
|
||||
{t('toolbarHint', {
|
||||
shortcut: formatShortcutLabel(
|
||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||
),
|
||||
})}
|
||||
</KeyboardShortcutHint>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -69,10 +69,10 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
iconComponent={iconComponent}
|
||||
placeholder={items.length === 0 ? t('loading') : t('select')}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={(key) => {
|
||||
onSelectionChange={async (key) => {
|
||||
if (key === selectedKey) return
|
||||
await setActiveMediaDevice(key as string)
|
||||
onSubmit?.(key as string)
|
||||
setActiveMediaDevice(key as string)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Optionen für {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
|
||||
"toolbarHint": "{{shortcut}}: Direkt auf die Tastenkürzel zugreifen.",
|
||||
"pin": {
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
|
||||
@@ -619,7 +619,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options for {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
|
||||
"toolbarHint": "{{shortcut}}: access shortcuts directly.",
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
|
||||
@@ -619,7 +619,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options pour {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
|
||||
"toolbarHint": "{{shortcut}} : accéder directement aux raccourcis.",
|
||||
"pin": {
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
|
||||
@@ -619,7 +619,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opties voor {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
|
||||
"toolbarHint": "{{shortcut}}: direct toegang tot de sneltoetsen.",
|
||||
"pin": {
|
||||
"enable": "Pinnen",
|
||||
"disable": "Losmaken"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
align="center"
|
||||
src="{{logo_img}}"
|
||||
width="320px"
|
||||
alt="{%trans 'Logo email' %}"
|
||||
alt="{%trans 'Logo email' %} {{brandname}}"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
@@ -23,8 +23,7 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Laden Sie Ihre Aufnahme herunter, "
|
||||
"indem Sie [diesem Link folgen]({download_link})*\n"
|
||||
"\n*[Laden Sie hier Ihre Aufnahme herunter (externer Link)]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
||||
document_default_title="Transkription",
|
||||
|
||||
@@ -23,7 +23,7 @@ A few things we recommend you check:
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Download your recording by [following this link]({download_link})*\n"
|
||||
"\n*[Download your recording (external link)]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Unable to transcribe text]",
|
||||
document_default_title="Transcription",
|
||||
|
||||
@@ -23,7 +23,7 @@ Quelques points que nous vous conseillons de vérifier :
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
|
||||
"\n*[Télécharger votre enregistrement (lien externe)]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Texte impossible à transcrire]",
|
||||
document_default_title="Transcription",
|
||||
|
||||
@@ -23,7 +23,7 @@ Een paar punten die wij u aanraden te controleren:
|
||||
|
||||
""",
|
||||
download_header_template=(
|
||||
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
|
||||
"\n*[Download hier je opname (externe link)]({download_link})*\n"
|
||||
),
|
||||
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
||||
document_default_title="Transcriptie",
|
||||
|
||||
Reference in New Issue
Block a user